Rustango docs
← Anleitungen

Sicherheitsleitfaden

Dieser Leitfaden behandelt jede Sicherheitsfunktion, die Rustango mitbringt, und wie man sie kombiniert. Wenn du schon einmal ein Web-Framework benutzt hast, werden dir die meisten davon vertraut vorkommen — die Namen unterscheiden sich, aber die Ideen sind dieselben. Jede der folgenden Funktionen benötigt in der Regel eine Zeile Setup. Wenn du bereit bist, in Produktion zu gehen, führe manage check --deploy für ein automatisiertes Audit aus.

Der gehärtete Middleware-Stack in einer Kette verdrahtet: Request-IDs, Zugriffsprotokollierung, Rate Limiting, CORS und Security-Header

Inhaltsverzeichnis


Die Defense-in-Depth-Checkliste

Gute Sicherheit entsteht aus vielen kleinen Schichten, nicht aus einer großen Mauer. Eine produktive Rustango-App sollte die folgenden Schichten stapeln — die meisten davon sind jeweils eine Zeile. Der Rest dieses Leitfadens erklärt jede einzelne.

let app = Router::new()
    .route("/...", ...)
    .merge(health::health_router(pool.clone()))         // /health, /ready
    .request_id(RequestIdLayer::default())              // log correlation
    .access_log(AccessLogLayer::default())              // PII-redacted by default
    .rate_limit(RateLimitLayer::per_ip(60, ONE_MIN))    // 60 req/min/IP
    .cors(CorsLayer::new().allow_origins(vec!["..."])) // explicit allowlist
    .security_headers(                                   // HSTS + XFO + nosniff + Referrer + CSP
        SecurityHeadersLayer::strict()
            .csp(CspBuilder::strict_starter().build()),
    );
// Plus: TLS termination at reverse proxy.
// Plus: argon2 for passwords, JWT lifecycle for tokens, TOTP for MFA.

Security-Header setzen

Security-Header teilen dem Browser mit, wie er deine Benutzer schützen soll (Clickjacking blockieren, HTTPS erzwingen, Content-Type-Sniffing unterbinden). SecurityHeadersLayer setzt das Standardset mit einer Zeile — die gängigen Schutz-Header, ohne dass du sie einzeln benennen musst. (Ein „Layer" ist Rustangos Begriff für Middleware; du hängst sie an deinen Router an.)

Vertiefung: Middleware behandelt, wie Layer funktionieren, ihre Reihenfolge, den vollständigen eingebauten Katalog und das Schreiben eigener Layer (locale-, zeitzonenbewusst, Header, CSRF).

use rustango::security_headers::{SecurityHeadersLayer, SecurityHeadersRouterExt, CspBuilder};

let app = router.security_headers(SecurityHeadersLayer::strict());

Presets

PresetWann verwenden
strict()Produktion: HSTS preload + XFO=DENY + nosniff + Referrer-Policy=no-referrer + COOP=same-origin + Permissions-Policy gesperrt
relaxed()Einbettbar in iframes: SAMEORIGIN + 1 Jahr HSTS
dev()Lokal: nur nosniff (kein HSTS, um localhost nicht für immer in HTTPS zu sperren)
empty()Von Grund auf aufbauen

Benutzerdefinierte CSP

let csp = CspBuilder::new()
    .default_src(&["'self'"])
    .script_src(&["'self'", "https://cdn.example.com"])
    .style_src(&["'self'", "'unsafe-inline'"])    // for inline <style>
    .img_src(&["'self'", "data:", "https:"])
    .font_src(&["'self'", "data:"])
    .connect_src(&["'self'", "wss://realtime.example.com"])
    .frame_ancestors(&["'none'"])                 // disallow embedding (clickjacking)
    .object_src(&["'none'"])
    .directive("base-uri", &["'self'"])
    .build();

let layer = SecurityHeadersLayer::strict().csp(csp);

Gestaffelter CSP-Rollout

Nutze csp_report_only, um zu überwachen, ohne die Produktion zu brechen:

let layer = SecurityHeadersLayer::strict()
    .csp(CspBuilder::strict_starter().build())
    .csp_report_only(true);                       // sends Content-Security-Policy-Report-Only

Nachdem du verifiziert hast, dass keine Konsolenfehler auftreten → schalte csp_report_only(false), um zu erzwingen.


Cross-Origin-Anfragen erlauben (CORS)

CORS steuert, welche anderen Websites deine API aus einem Browser heraus aufrufen dürfen. Standardmäßig blockieren Browser diese Aufrufe; du lässt bestimmte Origins gezielt wieder zu. Liste in Produktion deine echten Frontend-Domains auf und öffne es niemals für alle.

use rustango::cors::{CorsLayer, CorsRouterExt};

// Production
let layer = CorsLayer::new()
    .allow_origins(vec!["https://app.example.com", "https://admin.example.com"])
    .allow_methods(vec!["GET", "POST", "PUT", "PATCH", "DELETE"])
    .allow_headers(vec!["content-type", "authorization"])
    .allow_credentials(true)
    .max_age(Duration::from_secs(3600));

// Dev only
let layer = CorsLayer::permissive();              // any origin, common methods

Sicherheitshinweis: Mit Credentials MUSST du explizite Origins auflisten. allow_credentials(true) zusammen mit allow_any_origin() ergibt kein „credentialed Wildcard-CORS" — das gibt es nicht, und wer es verlangt, bekommt keine der beiden Hälften.

Rustango antwortet darauf mit Access-Control-Allow-Origin: * und ohne Access-Control-Allow-Credentials-Header, sodass der Browser die Anfrage mit Credentials blockiert. Bis #1394 wurde stattdessen die anfragende Origin zurückgespiegelt — und genau das akzeptieren Browser zusammen mit Credentials. Diese Kombination war ein funktionierendes Loch, lesbar von jeder Seite, die der Nutzer besuchte, und diese Seite beschrieb es als etwas, das der Browser ablehnen würde.

Nutze .allow_origins([...]) mit den Origins, die du tatsächlich bedienst. Dieser Pfad ist unverändert und sendet weiterhin Access-Control-Allow-Credentials: true.


Anfragen per Rate Limiting drosseln

Rate Limiting begrenzt, wie viele Anfragen ein Client in einem Zeitfenster stellen kann. Nutze es, um Brute-Force-Logins, Scraping und Missbrauch auszubremsen. Du kannst pro IP, pro API-Key oder global für einen teuren Endpunkt begrenzen.

use rustango::rate_limit::{RateLimitLayer, RateLimitRouterExt};

// Per-IP
router.rate_limit(RateLimitLayer::per_ip(60, Duration::from_secs(60)));

// Per-API-key (header)
router.rate_limit(RateLimitLayer::per_header("authorization", 1000, Duration::from_secs(3600)));

// Global ceiling for an expensive endpoint
router.rate_limit(RateLimitLayer::global(10, Duration::from_secs(1)));

Bei Erschöpfung: 429 Too Many Requests mit Retry-After-Header. Jede erfolgreiche Antwort enthält X-RateLimit-Limit + X-RateLimit-Remaining.

Hinter einem Reverse Proxy: benenne deine Proxys, sonst limitiert per_ip nichts Sinnvolles (#1398). RateLimitLayer::per_ip schlüsselt auf den verbindenden Socket, was hinter einem Proxy die Adresse des Proxys ist — also teilen sich alle Clients einen Bucket. Ein einziger lauter Client limitiert dann alle anderen, und kein einzelner Angreifer wird je limitiert.

Mounte RealIpLayer und sag ihm, welchen Hops er glauben soll:

use rustango::real_ip::{HeaderStrategy, RealIpLayer, RealIpRouterExt};

app.rate_limit(RateLimitLayer::per_ip(60, Duration::from_secs(60)))
   .real_ip(
       RealIpLayer::new(HeaderStrategy::XForwardedFor)
           .trust_proxies(["10.0.0.0/8"])?,   // dein Ingress, nicht das Internet
   );

Die Reihenfolge ist entscheidend und scheitert lautlos. Layer werden von außen nach innen zuletzt angewandt, real_ip muss also nach rate_limit hinzugefügt werden, um davor zu laufen. Andersherum sieht der Limiter keine aufgelöste Adresse und schlüsselt still wieder auf den Socket — er warnt einmal, wenn ein Forwarding-Header ohne aufgelöste Adresse eintrifft.

trust_proxies ist keine optionale Dekoration. X-Forwarded-For setzt, wer ihn sendet. Ohne deklarierte Proxy-Liste ist der Header nur eine Behauptung, also ignoriert ihn der Limiter vollständig und schlüsselt auf den Socket — absichtlich. Täte er es nicht, könnte jeder Client durch Variieren eines Headers pro Anfrage einen frischen Bucket erzeugen und aus „das Limit ist zu grob" würde „es gibt kein Limit". RealIp (die Behauptung) ist für Logging in Ordnung; nur TrustedRealIp, das erscheint, wenn der Peer zu trust_proxies passt, schlüsselt einen Limiter.

Benenne die Adressen, von denen dein Ingress tatsächlich verbindet. Einen weiteren Bereich zu vertrauen, reicht den Bypass an jeden darin weiter.

RateLimitLayer ist prozesslokal — es zählt Anfragen nur innerhalb einer laufenden Instanz, was in Ordnung ist, wenn du eine einzelne Instanz betreibst. Wenn du mehrere Instanzen (Replicas) hinter einem Load Balancer betreibst, würde jede ihre eigene Zählung führen, sodass sich das reale Limit vervielfacht. Um eine Zählung über alle Replicas hinweg zu teilen, nutze rate_limit_cache::CacheRateLimitLayer, das an eine beliebige cache::Cache-Implementierung delegiert (kombiniere es mit cache::RedisCache für einen gemeinsamen Zähler, der atomar per Redis INCRBY inkrementiert wird):

use rustango::cache::RedisCache;
use rustango::rate_limit::KeyBy;
use rustango::rate_limit_cache::{CacheRateLimitLayer, CacheRateLimitRouterExt};

let cache: rustango::cache::BoxedCache =
    std::sync::Arc::new(RedisCache::new("redis://localhost").await?);

let app = axum::Router::new()
    .route("/api/login", axum::routing::post(login))
    .cache_rate_limit(
        CacheRateLimitLayer::new(cache, 5, Duration::from_secs(60))
            .key_by(KeyBy::Ip)
            .key_prefix("login"),
    );

Nach einem geheimen Header zu keyen ist teilbar-sicher. Bei key_by(KeyBy::Header("authorization")) oder "x-api-key" hasht der cache-gestützte Limiter den Header-Wert, bevor er ihn als Schlüssel verwendet (#1252) — ein geteiltes Redis speichert also nie eine gültige Anmeldeinformation dort, wo jemand mit Schlüssel-Lesezugriff sie abgreifen könnte. KeyBy::Ip braucht ConnectInfo (oder ein vorgeschaltetes RealIpLayer); fehlt das, warnt der Limiter einmalig und fällt auf einen einzigen geteilten Bucket zurück.


IPs erlauben oder blockieren

Sperre sensible Routen (wie dein Admin) auf eine bekannte Menge von IP-Adressen ein oder blockiere bestimmte Missbraucher. Eine Allowlist erlaubt nur die aufgeführten Netzwerke; eine Blocklist verweigert die aufgeführten.

use rustango::ip_filter::{IpFilterLayer, IpFilterRouterExt};

// Internal admin only
let admin_router = admin_router
    .ip_filter(IpFilterLayer::allow_only(vec![
        "10.0.0.0/8",
        "192.168.0.0/16",
        "203.0.113.0/24",
    ])?);

// Block known abusers
let public_router = public_router
    .ip_filter(IpFilterLayer::block(vec!["203.0.113.42"])?);

IPv4 + IPv6 werden unterstützt. Familienübergreifend sicher (ein IPv4-CIDR matcht keine IPv6-Adressen). Gibt bei Ablehnung 403 Forbidden zurück.

Wichtig: Rustango liest die Client-IP aus ConnectInfo<SocketAddr>. Mounte mit:

axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await?;

Wenn dein Reverse Proxy X-Forwarded-For weiterleitet, konfiguriere ihn so, dass er RemoteAddr setzt — ip_filter liest den verbindenden Peer, nicht die Header.


Schutz vor CSRF

CSRF und Token-APIs. CSRF schützt Cookie-authentifizierte Anfragen. Eine reine Token- / Bearer- / JWT-API ist kein CSRF-Ziel — wende den CSRF-Layer nicht darauf an (sonst gibst du legitimen Clients ein 403). Für eine SPA, die tatsächlich Cookies verwendet, lies das rustango_csrf-Cookie und gib es im X-CSRF-Token- Header zurück.

CSRF (Cross-Site Request Forgery) liegt vor, wenn eine andere Website den Browser eines eingeloggten Benutzers dazu bringt, eine Anfrage an deine App zu senden. Die Verteidigung ist ein geheimes Token in jedem Formular, das der Server bei jedem Schreibvorgang gegenprüft. Die CSRF-Middleware liegt in rustango::forms::csrf (hinter dem csrf-Feature, das durch das admin-Feature automatisch aktiviert wird):

use rustango::forms::csrf;

let app = Router::new()
    .route("/contact", get(form).post(submit))
    .layer(csrf::layer());

csrf::layer() baut den Layer mit secure: true, das Cookie wird also über reines HTTP abgelehnt — auf http://localhost nimm CsrfConfig::allow_insecure_for_dev(), sonst scheint der Layer nichts zu tun. csrf::with_config(CsrfConfig) überschreibt die Cookie-/Header-Namen und das Secure-Flag sowie trusted_origins — standardmäßig leer, die Origin-Header-Prüfung ist also aus, bis du einen hinzufügst. In Templates gibt {{ csrf_token }} das rohe Token und {{ csrf_input }} ein fertiges verstecktes <input> — schreib es als {{ csrf_input | safe }}, denn Tera escapt .html-Templates automatisch: ohne den Filter rendert die Seite ein sichtbares literales <input …>, das Formular trägt kein _csrf-Feld und jedes POST endet in 403. Beide Variablen liegen nur für die template_views-CBVs im Context oder nachdem du forms::csrf::stamp_into_context selbst aufgerufen hast — ein handgeschriebener Handler hat keine davon. Es verwendet das Double-Submit-Cookie-Muster: bei unsicheren Methoden (POST, PUT, PATCH, DELETE) prüft der Layer den X-CSRF-Token-Header (oder das _csrf-Formularfeld) gegen das rustango_csrf-Cookie; eine Nichtübereinstimmung gibt 403 Forbidden zurück.

Collector-Endpunkte ausnehmen. CsrfConfig::exempt_prefix("/path") (wiederholbar) überspringt die CSRF-Durchsetzung für unsichere Methoden bei Anfragen, deren Pfad mit dem angegebenen Präfix beginnt. Das ist für Append-only-, zustandslose Endpunkte gedacht, die per navigator.sendBeacon angesprochen werden — z. B. ein Analytics-Collector — die keinen X-CSRF-Token-Header setzen können und, wenn die Seite aus einem CDN-Cache ausgeliefert wird, der Set-Cookie entfernt, möglicherweise gar kein CSRF-Cookie mitführen. Halte Präfixe eng und nimm niemals etwas aus, das Auth-Zustand liest oder schreibt.

Der Auto-Admin. Sobald du .with_session_auth(...) aufrufst, wird CSRF automatisch auf jede Admin-Mutation gelegt — anlegen, ändern, löschen, Bulk-Aktionen, Audit-Cleanup — und jedes Admin-Formular rendert seinen Token selbst. Es gibt nichts zu verdrahten und keine Möglichkeit, sich davon abzumelden.

Die Bedingung ist Absicht, kein Zufall: CSRF schützt Credentials, die der Browser von sich aus mitschickt, also ist es das Session-Cookie, das es überhaupt sinnvoll macht. Ein Admin ohne with_session_auth hat kein von rustango verwaltetes Credential, mit dem sich fälschen ließe, und seine Mutationen sind für jeden direkt erreichbar, der die Route erreicht — das ist eine Authentifizierungslücke, keine CSRF-Lücke, und CSRF würde sie nicht verkleinern. Wer eine eigene Cookie-Auth davorsetzt, hängt csrf::layer() selbst ein.

Bis #1395 behauptete dieser Absatz, der Schutz sei bedingungslos — geschützt war allein POST /login. Jede andere Admin-Mutation akzeptierte ein Cross-Site-POST auf der Session des Administrators, Audit-Cleanup eingeschlossen, sodass dieselbe Anfrageklasse ihre eigene Spur löschen konnte.


XSS verhindern

XSS (Cross-Site Scripting) tritt auf, wenn Benutzereingaben als HTML gerendert werden und als Code im Browser einer anderen Person laufen. Die Lösung ist, jede Benutzereingabe zu escapen, bevor sie die Seite erreicht. Rustango löst das auf zwei Wegen:

1. Tera-Template-Auto-Escape — Tera ist Rustangos Template-Engine. Jedes {{ var }} wird automatisch HTML-escapt — aber nur in Templates, die Tera autoescapt, also seinem Standard-Satz .html, .htm und .xml. Rustango setzt keine autoescape_suffixes, ein .txt-, .j2- oder .tera-Template wird also nicht escapt. Verwende {{ var | safe }}, um dich abzumelden — selten und gefährlich, also tue das nur für HTML, dem du vollständig vertraust.

2. Manueller Escape-Helper — für den Fall, dass du HTML in Rust-Code statt in einem Template baust:

use rustango::text::html_escape;

let safe = html_escape(user_input);
// "<script>" → "&lt;script&gt;"
// "a & b" → "a &amp; b"

Ersetzt &, <, >, ", '. Geeignet für HTML-Elementinhalte + doppelt-quotierte Attribute.

Für CSP-basierte XSS-Verteidigung siehe Security-Header setzen.


SQL-Injection verhindern

SQL-Injection tritt auf, wenn Benutzereingaben direkt in einen SQL-String eingefügt werden und die Query verändern. Rustangos ORM verhindert das durch Design: es verwendet überall sqlx-Parameter-Binding — jeder Wert wird als $1, $2, ...-Platzhalter gesendet, niemals in den Query-Text eingeklebt. (sqlx ist die zugrunde liegende Datenbankbibliothek.) Du kannst über die ORM-API nicht versehentlich Benutzereingaben in SQL konkatenieren.

Es gibt zwei Stellen, bei denen du weiterhin vorsichtig sein solltest:

1. Tabellen- und Spaltennamen in bulk_actions:

Platzhalter schützen Werte, können aber keinen Bezeichner (einen Tabellen- oder Spaltennamen) tragen, also benötigen diese ihren eigenen Schutz. Der Bulk-Action-Runner klebt niemals rohe, benutzergelieferte Tabellen-/Spaltennamen in SQL. Intern validiert er jeden Bezeichner (ein crate-privates validate_ident, das ", `, \0, \n, \r, \, ;, Leerzeichen und Steuerzeichen ablehnt) und quotet ihn dann per dialect.quote_ident(), bevor die Anweisung gebaut wird. Dieser Schutz läuft für dich — du rufst ihn nicht selbst auf.

2. Der Raw-SQL-Notausgang:

Wenn du auf rohes sqlx heruntergehst, binde jeden Wert als Parameter und füge niemals einen Bezeichner (Tabellen- oder Spaltenname), der aus Benutzereingaben stammte, in den Query-String ein — Platzhalter können keine Bezeichner tragen:

// ✅ values go through bound placeholders
sqlx::query("SELECT * FROM posts WHERE id = $1")
    .bind(1).fetch_all(&pool).await?;

// ❌ NEVER interpolate a user-supplied identifier into the SQL string
let sql = format!("SELECT * FROM \"{user_table}\" WHERE id = $1");
sqlx::query(&sql).bind(1).fetch_all(&pool).await?;

Benutzer authentifizieren

Authentifizierung ist die Art, wie du bestätigst, wer eine Anfrage stellt. Rustango bringt drei fertige Backends mit (Basic Auth, API-Keys und JWTs) und lässt dich eigene schreiben, indem du ein einziges Trait implementierst. Du hängst sie an Routen an, und Anfragen ohne ein erkanntes Credential erhalten ein 401.

Admin-SSO. Um Betreibern zu erlauben, sich mit einem externen IdP (Google, Microsoft/Azure AD, GitHub oder einem beliebigen OpenID-Connect-Provider) statt mit einem Passwort im Admin anzumelden, aktiviere das admin-sso-Feature — siehe den SSO-Leitfaden. Provider werden im Admin-UI als Zeilen verwaltet (mehrere pro Surface; pro Tenant oder ein gemeinsames Set über Tenants hinweg), wobei das Client-Secret verschlüsselt gespeichert wird. Es ist Link-to-existing (die verifizierte IdP-E-Mail muss mit einem Admin-Benutzer übereinstimmen; kein Auto-Provisioning) und verwendet die bestehende Session wieder.

Drei fertige Backends

use rustango::tenancy::auth_backends::{ModelBackend, ApiKeyBackend, JwtBackend};
use rustango::tenancy::middleware::{RouterAuthExt, CurrentUser};
use std::sync::Arc;

let backends = vec![
    Arc::new(ModelBackend) as _,                   // Authorization: Basic <b64>
    Arc::new(ApiKeyBackend) as _,                   // Authorization: Bearer <prefix>.<secret>
    Arc::new(JwtBackend::new(secret)) as _,         // Authorization: Bearer <jwt>
];

// `require_auth` / `require_perm` are `Router::layer` calls: each wraps the
// routes registered BEFORE it. Chaining them on one router would put /me
// behind post.add. Gate the permission on an inner sub-router instead.
let posts = Router::new()
    .route("/posts/new", post(create_post))
    .require_perm("post.add");     // inner: needs the codename

let app = Router::new()
    .route("/me", get(profile))
    .merge(posts)
    .require_auth(backends);       // outer: resolves the user first

Die Middleware probiert jedes Backend der Reihe nach. Das erste, das erfolgreich ist, gewinnt; das erste, das einen harten Fehler zurückgibt, stoppt die Kette.

Ein eigenes Backend schreiben

use rustango::tenancy::auth_backends::{AuthBackend, AuthUser, AuthError};
use async_trait::async_trait;

pub struct OAuthBackend { /* ... */ }

#[async_trait]
impl AuthBackend for OAuthBackend {
    async fn authenticate(&self, parts: &Parts, pool: &rustango::sql::Pool)
        -> Result<Option<AuthUser>, AuthError>
    {
        // Read your custom header, validate, look up the user
        Ok(None)  // means: not my request, try next backend
    }
}

Passwörter hashen und prüfen

Speichere Passwörter niemals als Klartext. Hashe sie bei der Registrierung mit hash und vergleiche beim Login den Versuch mit verify. Nutze strength_score, um schwache Passwörter abzulehnen, bevor du sie überhaupt hashst.

use rustango::passwords::{hash, verify, strength_score, StrengthIssue};

// Signup
let issues = strength_score(&new_password);
if !issues.is_empty() {
    return Err(format!("password too weak: {issues:?}"));
}
let hashed = hash(&new_password)?;

// Login
let ok = verify(&attempted, &user.password_hash)?;

Das Hashing verwendet argon2id (der von OWASP empfohlene Standard seit 2023 — es widersteht GPU-Cracking besser als bcrypt). Hashes werden im PHC-String-Format gespeichert, sodass du Parameter später ändern kannst, ohne alte Hashes zu brechen.

Die eingebaute Stärkeprüfung ist bewusst minimal. Für ernsthafte Deployments prüfe Passwörter zusätzlich gegen die HIBP- / pwned-passwords-API, um bekanntermaßen geleakte abzulehnen.

Vertiefung: Passwörter — argon2id-Interna, automatisches Salting, timing-sichere Logins (verify_dummy) und wo der Hash liegt. Sobald authentifiziert, übergib an eine Session (Browser) oder ein JWT (API).


JWTs ausstellen und erneuern

Ein JWT (JSON Web Token) ist ein signiertes Token, das ein Client sendet, anstatt sich jedes Mal einzuloggen. JwtLifecycle handhabt den gesamten Ablauf: ein kurzlebiges Access-Token plus ein länger lebendes Refresh-Token, mit eingebautem Verify, Refresh und Revoke.

use rustango::tenancy::jwt_lifecycle::{JwtLifecycle, JwtTokenPair, JwtIssueError};
use serde_json::json;

let jwt = JwtLifecycle::new(secret)
    .with_access_ttl(900)                         // 15 min
    .with_refresh_ttl(7 * 86400);                 // 7 days

Ein Token mit benutzerdefinierten Claims ausstellen (kein DB-Lookup beim Verify)

let pair = jwt.issue_pair_with(user_id, json!({
    "roles":  ["admin", "editor"],
    "tenant": "acme",
    "scope":  "read:posts write:posts",
    "email":  "[email protected]",
}).as_object().unwrap().clone())?;

„Claims" sind die Schlüssel/Wert-Fakten, die du in das Token einbettest (Rollen, Tenant und so weiter); der Client sendet sie zurück und du liest sie, ohne die Datenbank anzusprechen. Reservierte Claim-Namen (sub, exp, jti, typ) werden vom Framework verwendet und dürfen nicht in deinen benutzerdefinierten Claims auftauchen — der Versuch gibt JwtIssueError::ReservedClaim zurück.

Ein Token verifizieren

let claims = jwt.verify_access(&access_token).await
    .ok_or(StatusCode::UNAUTHORIZED)?;

let roles: Vec<String> = claims.get_custom("roles").unwrap_or_default();
let tenant: String = claims.get_custom("tenant").unwrap_or_default();

Ein Token erneuern (behält deine benutzerdefinierten Claims)

let new_pair = jwt.refresh(&refresh_token).await
    .ok_or(StatusCode::UNAUTHORIZED)?;
// new_pair has the same roles + scope + tenant

Die JTI des alten Refresh-Tokens (seine eindeutige ID) wird zu einer Blacklist hinzugefügt, sodass es nicht wiederverwendet werden kann — das blockiert Replay-Angriffe, bei denen ein gestohlenes altes Token erneut gesendet wird.

Erneuern mit neu geprüften Berechtigungen

// returns Result<Option<JwtTokenPair>, JwtIssueError>:
// Err on a reserved-claim collision, Ok(None) on an invalid/expired refresh.
let new_pair = jwt.refresh_with(&refresh_token, json!({
    "roles": ["viewer"],     // role was demoted since login
    "tenant": "acme",
}).as_object().unwrap().clone()).await?
    .ok_or(StatusCode::UNAUTHORIZED)?;

Ein Token widerrufen

jwt.revoke(&access_token).await;     // adds JTI to blacklist
jwt.revoke(&refresh_token).await;

Die standardmäßige In-Memory-Blacklist (InMemoryJtiStore) räumt abgelaufene Einträge von selbst aus, aber sie lebt in einem Prozess und vergisst bei jedem Neustart jeden Widerruf. Für Multi-Prozess-Deployments installiere einen gemeinsamen, dauerhaften Store über JwtLifecycle::new(secret).with_jti_store(store) — store ist ein beliebiger Arc<dyn JtiStore> (zum Beispiel ein Redis- oder DB-gestützter). Ohne einen gemeinsamen Store kann ein Token, das du auf einer Replica widerrufst, auf einer anderen weiterhin abgespielt werden, bis es abläuft. Seit v0.52 (#1191) sind all diese Aufrufe async — verify_*, refresh* und revoke warten auf den Store, sodass ein dauerhafter Store ein einzelner bedingter Schreibvorgang sein kann statt eines letztlich konsistenten Caches mit Hintergrund-Flusher.

Vertiefung: JWT-Auth-API — der eingebaute /api/auth/login|refresh|logout|me-Router, gleitendes Refresh und der JTI-Widerrufs-Store. Für ein einzelnes selbstverwaltetes Token siehe JWT (standalone). Um Browser-/API-Routen per Login zu gaten, siehe Access-Decorators.


Mit API-Keys authentifizieren

API-Keys erlauben Skripten und Services die Authentifizierung ohne Benutzername, Passwort oder Session — praktisch für Machine-to-Machine-Zugriff. Du generierst einen Key, zeigst ihn dem Benutzer einmal und speicherst nur seinen Präfix und Hash.

use rustango::api_keys::{generate_key, verify_key, split_token};

// Issuance
let (full_token, prefix, hash) = generate_key()?;
// Format: {8-char hex prefix}.{32-char hex secret}
// Show full_token to the user once. Store prefix + hash in your DB.

// Verification on each request
let (prefix, secret) = split_token(&inbound_header)
    .ok_or(StatusCode::UNAUTHORIZED)?;
let row = lookup_by_prefix(prefix).await?;
if !verify_key(secret, &row.hash)? {
    return Err(StatusCode::UNAUTHORIZED);
}

Diese Keys funktionieren direkt mit dem Multi-Tenancy-ApiKeyBackend — beide verwenden dasselbe {prefix}.{secret}-Format mit argon2id-gehashten Secrets, sodass ein hier ausgestellter Key dort ohne zusätzliche Arbeit authentifiziert.


Zwei-Faktor-Authentifizierung hinzufügen (TOTP)

TOTP ist der 6-stellige Code aus einer Authenticator-App — ein zweiter Faktor zusätzlich zum Passwort. Du generierst pro Benutzer ein Secret, zeigst es als QR-Code zum Einrichten an und verifizierst dann bei jedem Login den eingetippten Code. Es folgt dem RFC-6238-Standard.

use rustango::totp::{TotpSecret, otpauth_url, verify};

// Enrollment
let secret = TotpSecret::generate();                           // 20 random bytes
user.totp_secret = secret.to_base32();                          // store in DB
let qr_url = otpauth_url("MyApp", &user.email, &secret);        // encode as QR

// Verification on login
let secret = TotpSecret::from_base32(&user.totp_secret).unwrap();
if !verify(&secret, &user_supplied_code, 30, 6, 1) {            // 6 digits, ±30s drift
    return Err("bad TOTP code");
}

Funktioniert mit Google Authenticator, Authy, 1Password, Bitwarden und anderen Standard-Authenticator-Apps.

Recovery-Codes (einmalige Backup-Codes für den Fall, dass ein Benutzer sein Telefon verliert) werden noch nicht mitgeliefert. Das gängige Muster ist, 8–10 gehashte Codes pro Benutzer zu speichern und einen bei jeder Verwendung zu verbrauchen.


Signierte URLs versenden (Magic Links)

Eine signierte URL trägt eine manipulationssichere Signatur, sodass du ihr ohne Session vertrauen kannst — perfekt für Magic-Link-Logins, Passwort-Resets und zeitlich begrenzte Download-Links. Du signst eine URL (optional mit einem Ablaufdatum) und verifyst sie, wenn der Benutzer klickt. (Laravel nennt diese „signed routes".)

use rustango::signed_url::{sign, verify, SignedUrlError};
use std::time::Duration;

// Issue a 1-hour magic-link login URL
let url = sign(
    "https://app.example.com/auth/[email protected]",
    secret,
    Some(Duration::from_secs(3600)),
);
// Send via email...

// On the callback handler
match verify(&incoming_url, secret) {
    Ok(()) => { /* identity confirmed */ }
    Err(SignedUrlError::Expired) => { /* prompt to request a new link */ }
    Err(SignedUrlError::InvalidSignature) => { /* tampered — log + 401 */ }
    Err(_) => { /* malformed */ }
}

Häufige Einsatzzwecke:

  • Magic-Link-Login (eine einmalige URL per E-Mail versenden, statt nach einem Passwort zu fragen)
  • Passwort-Reset-Bestätigung
  • Zeitlich begrenzte Datei-Downloads
  • „Hier klicken, um deine E-Mail zu verifizieren"-Links
  • Abmelde-Links (keine Auth nötig; die URL selbst beweist die Absicht)

Vor dem Signieren werden die Query-Parameter in eine feste Reihenfolge sortiert, sodass ein Angreifer sie nicht umordnen kann (zum Beispiel durch Verschieben von ?expires=...), um zu ändern, was die Signatur abdeckt, und einen gültig aussehenden Link zu fälschen.

Vertiefung: Account-Flows baut Passwort-Reset, E-Mail-Verifizierung und Magic-Link-Login auf diesem Signed-URL-Primitive auf.


Eingehende Webhooks verifizieren

Ein Webhook ist ein HTTP-Callback, den ein anderer Service dir sendet (eine Zahlung war erfolgreich, ein Push ist passiert). Prüfe immer seine Signatur, damit du weißt, dass er wirklich von diesem Service kam und der Body nicht verändert wurde. verify_signature handhabt die gängigen Formate.

use rustango::webhook::{verify_signature, SignatureFormat};

async fn handle_stripe_webhook(headers: HeaderMap, body: Bytes) -> impl IntoResponse {
    let signature = headers.get("stripe-signature")
        .and_then(|v| v.to_str().ok())
        .unwrap_or("");

    if !verify_signature(SignatureFormat::HexSha256, secret, &body, signature) {
        return StatusCode::UNAUTHORIZED;
    }
    // ... process the verified payload
    StatusCode::OK
}
FormatVerwendet von
HexSha256WithPrefixGitHub (sha256=<hex>)
HexSha256Slack, rohe HMAC-Provider
Base64Sha256Stripe, AWS SNS

Der Signaturvergleich ist konstant-zeitlich, das heißt, er dauert immer gleich lang, egal ob die Vermutung richtig oder falsch ist. Das stoppt Timing-Angriffe, bei denen ein Angreifer winzige Antwortzeit-Unterschiede misst, um das Secret Zeichen für Zeichen zu erraten.


Secrets aus deinen Logs heraushalten

Geloggte URLs können Passwörter und Tokens leaken, die in Query-Strings auftauchen. AccessLogLayer maskiert automatisch bekannte Credential-Parameter, bevor die Log-Zeile geschrieben wird (PII = personenbezogene Daten).

let app = router.access_log(AccessLogLayer::default());

Standardmäßig werden Werte für diese Query-Parameter durch [redacted] ersetzt: password, passwd, token, secret, api_key, apikey, access_token, refresh_token, signature, auth.

Zur Liste hinzufügen:

let layer = AccessLogLayer::default()
    .redact_additional("session_id")
    .redact_additional("private_key");

Die gesamte Liste ersetzen:

let layer = AccessLogLayer::default()
    .redact(vec!["only_this".into()]);

Hinweis: Dies redaktiert nur Query-Strings. Um Request-Bodies oder Header zu bereinigen, filtere an der Quelle mit tracing::Subscriber.


Anfragen über Services hinweg nachverfolgen

Eine Request-ID versieht jede Log-Zeile für eine Anfrage mit derselben ID, sodass du dieser Anfrage über Handler hinweg — und über Services hinweg — folgen kannst. RequestIdLayer fügt jeder Anfrage eine hinzu.

let app = router.request_id(RequestIdLayer::default());

Es verwendet eine eingehende X-Request-Id wieder (sodass verkettete Services eine ID teilen) oder generiert eine frische 22-Zeichen-Base64-ID. Eingehende IDs werden zuerst validiert — alles mit Steuerzeichen, Zeilenumbrüchen, Null-Bytes oder länger als 128 Zeichen wird abgelehnt, was Header-Injection-Angriffe blockiert.

In Handlern:

async fn handler(id: rustango::request_id::RequestId) -> String {
    tracing::info!(req_id = %id.0, "handling /me");
    format!("req {}", id.0)
}

In einem Zero-Trust-Setup, in dem du IDs, die von Upstream-Services gesendet werden, nicht vertraust, erstelle immer deine eigene:

router.request_id(RequestIdLayer::always_generate());

Secrets verwalten

Hardcode Secrets (Datenbank-Passwörter, API-Keys) niemals in deinem Quellcode. Lies sie zur Laufzeit über das Secrets-Trait, das darüber abstrahiert, wo sie tatsächlich liegen. (Ein „Trait" ist Rusts Version eines Interface.) Das eingebaute EnvSecrets liest aus Umgebungsvariablen.

use rustango::secrets::{Secrets, EnvSecrets, BoxedSecrets};
use std::sync::Arc;

// Env vars (with optional prefix)
let secrets: BoxedSecrets = Arc::new(EnvSecrets::with_prefix("MYAPP_"));

let db_pwd = secrets.require("DB_PASSWORD").await?;       // reads MYAPP_DB_PASSWORD
let redis_url = secrets.get("REDIS_URL").await?;          // None when unset

Um Secrets aus Vault, AWS Secrets Manager oder GCP Secret Manager zu ziehen, implementiere das Trait selbst — es ist nur eine async Methode.


Vor dem Deploy auditieren

Bevor du in Produktion gehst, führe das eingebaute Audit aus. Es fängt häufige Fehlkonfigurationen ab — wie ein fehlendes oder zu kurzes Signing-Secret — die du nicht in der Produktion entdecken möchtest.

manage check --deploy

Immer aktive Prüfungen (laufen mit oder ohne --deploy):

  • ✅ Modelle im Inventory registriert
  • ✅ DB erreichbar (SELECT 1)
  • ✅ Migrationen auf der Platte für registrierte Modelle

Zusätzliche --deploy-Prüfungen (Produktionshärtung):

  • ✅ RUSTANGO_ENV ist prod oder production
  • ✅ RUSTANGO_SESSION_SECRET gesetzt und dekodiert ≥ 32 Bytes (der HMAC-Schlüssel für Cookie- + JWT-Signierung — SECRET_KEY wird vom Framework nicht gelesen), ohne verbliebenen Scaffolder-Platzhalter. Es ist base64: openssl rand -base64 32 liefert 44 Zeichen. Ein Secret mit 32 Zeichen hat nur 24 Bytes und wird abgelehnt — seit #1396 von check --deploy und der Laufzeit gleichermaßen; genau darum geht es, denn die Prüfung maß früher die kodierte Zeichenkette und ließ einen Wert durch, den die App dann ablehnte
  • ✅ DATABASE_URL gesetzt (und warnt, wenn es auf localhost zeigt)
  • ⚠️ RUSTANGO_APEX_DOMAIN / RUSTANGO_BIND Plausibilitätswarnungen für Tenancy + Nicht-Loopback-Binding

Gibt bei jedem Befund auf Error-Level einen Exit-Code ungleich null zurück (gut für CI-Gates). Warnungen lösen kein Fehlschlagen aus, erscheinen aber in der Ausgabe.

Für Prüfungen jenseits des Mitgelieferten erweitere mit benutzerdefiniertem Code in deinem manage-Binary, bevor du an manage::run weiterleitest.


Was NOCH nicht mitgeliefert wird

Ein paar End-to-End-Flows müssen noch aus den untenstehenden Primitiven zusammengeklebt werden:

  • Passwort-Reset + E-Mail-Verifizierung — die Token-Issue-/Verify-Helper existieren (auth_flows::confirm_password_reset_pool, plus E-Mail-Verifizierungs-Round-Trips) und die E-Mail-Pipeline wird separat mitgeliefert, aber es gibt keinen einzelnen vorgefertigten View- + E-Mail- + Validate-Zyklus, der für dich verdrahtet ist.
  • PII-Redaktion von Request-Body / Headern in access_log — feldbasierte Log-Redaktion existiert (siehe Secrets aus deinen Logs heraushalten), aber keine automatische Bereinigung des Request-Bodys oder der Header.

Bereits mitgeliefert (greife nicht zu einem Workaround):

  • OAuth2 / OIDC Social Login — oauth2::providers bringt Google-, GitHub-, Microsoft-, GitLab- und Discord-Helper mit (plus OAuth2Provider::from_discovery für jeden OIDC-Provider), und oauth2::router::oauth2_router mountet die Login- + Callback-Routen, erstellt den Benutzerdatensatz und setzt das Session-Cookie.
  • Sperre pro Account — rustango::account_lockout::Lockout (cache-gestützt; is_locked / record_failure / clear, konfigurierbare max_attempts + lockout_duration).
  • CSP-Report-Endpunkt — security_headers::csp_report_router(path) + SecurityHeadersLayer::csp_report_uri(uri).
  • Verteiltes Rate Limiting — rate_limit_cache::CacheRateLimitLayer (siehe Anfragen per Rate Limiting drosseln).

Bis die nicht mitgelieferten Flows landen, klebe sie aus den obigen Primitiven zusammen (signed_url::sign für Passwort-Reset-Tokens, die E-Mail-Pipeline für die Zustellung usw.).


Siehe auch

  • Middleware — wie die Sicherheits-Layer angehängt werden, ihre Reihenfolge und der vollständige Katalog.
  • Authentifizierung — Passwörter, Sessions, JWTs, API-Keys, HMAC, Auth-Backends und Account-Flows, jeweils in der Tiefe.
  • API-Konventionen — die Fehlertyp- und Rückgabetyp-Regeln hinter diesen APIs.