Security guide
This guide covers every security feature Rustango ships and how to combine them. Each feature below is usually one line of setup. When you're ready to ship, run manage check --deploy for an automated audit.
Table of contents
- The defense-in-depth checklist
- Setting security headers
- Allowing cross-origin requests (CORS)
- Rate limiting requests
- Allowing or blocking IPs
- Protecting against CSRF
- Preventing XSS
- Preventing SQL injection
- Authenticating users
- Hashing and checking passwords
- Issuing and refreshing JWTs
- Authenticating with API keys
- Adding two-factor auth (TOTP)
- Sending signed URLs (magic links)
- Verifying incoming webhooks
- Keeping secrets out of your logs
- Tracing requests across services
- Managing secrets
- Auditing before you deploy
The defense-in-depth checklist
Good security comes from many small layers, not one big wall. A production Rustango app should stack the layers below — most are one line each. The rest of this guide explains each one.
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.
Setting security headers
Security headers tell the browser how to protect your users (block clickjacking, force HTTPS, stop content-type sniffing). SecurityHeadersLayer sets a safe default set with one line, and you can override any single header. (A "layer" is Rustango's term for middleware; you attach it to your router.)
Deep dive: Middleware covers how layers work, ordering, the full built-in catalog, and writing your own (locale-, timezone-aware, headers, CSRF).
use rustango::security_headers::{SecurityHeadersLayer, SecurityHeadersRouterExt, CspBuilder};
let app = router.security_headers(SecurityHeadersLayer::strict());
Presets
| Preset | When to use |
|---|---|
strict() | Production: HSTS preload + XFO=DENY + nosniff + Referrer-Policy=same-origin + COOP=same-origin + Permissions-Policy locked down |
relaxed() | Embeddable in iframes: SAMEORIGIN + 1y HSTS |
dev() | Local: nosniff only (no HSTS to avoid locking localhost into HTTPS forever) |
empty() | Build up from scratch |
SecurityHeadersLayer::default() is strict(). from_preset(name) picks one by name ("strict", "relaxed", "dev", "none" or "empty"); an unknown name gives strict(), never no headers. from_settings(&settings.security) reads [security] headers_preset, csp and hsts_max_age_secs (0 turns HSTS off). A header the handler already set is kept.
Two more layers belong here. host_validation::AllowedHostsLayer::new(["example.com", ".example.com"]) answers 400 to a request whose Host is not listed (.example.com also covers its subdomains). ssl_redirect::SslRedirectLayer::new() answers plain HTTP with a 301 to HTTPS; behind a TLS-terminating proxy add .proxy_ssl_header("X-Forwarded-Proto", "https"), or it redirects in a loop.
Custom CSP
let csp = CspBuilder::new()
.default_src(&["'self'"])
// The placeholder becomes a per-request nonce only under CspNonceLayer (below).
.script_src(&["'self'", "'nonce-__RUSTANGO_NONCE__'", "https://cdn.example.com"])
.style_src(&["'self'", "'nonce-__RUSTANGO_NONCE__'"])
.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);
'nonce-__RUSTANGO_NONCE__' (the constant csp_nonce::CSP_NONCE_PLACEHOLDER) is only a placeholder. Mount csp_nonce::CspNonceLayer::default() with .csp_nonce(...) after .security_headers(...), so it runs outside it: it makes a random nonce per request, puts it in the request as Extension<Nonce>, and replaces the placeholder in the CSP header. Without it the browser gets the literal placeholder and blocks every inline script and style.
The built-in error pages and the admin templates put the same nonce on their inline tags, so they pass this policy.
Staged CSP rollout
Use csp_report_only to monitor without breaking production:
let layer = SecurityHeadersLayer::strict()
.csp(CspBuilder::strict_starter().build())
.csp_report_only(true); // sends Content-Security-Policy-Report-Only
After verifying no console errors → flip csp_report_only(false) to enforce.
Allowing cross-origin requests (CORS)
CORS controls which other websites are allowed to call your API from a browser. By default browsers block these calls; you opt specific origins back in. List your real front-end domains in production and never open it up to everyone.
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
CorsLayer::new() allows no origin until you add some. CorsLayer::from_settings(&settings.security) reads [security] cors_allowed_origins: an empty list gives None (mount nothing), "*" gives permissive(), and named origins give an allowlist with the common methods (QUERY included) and the Content-Type, Authorization, X-Date, Idempotency-Key, If-Match, If-None-Match request headers. Both expose Retry-After, Idempotent-Replayed, ETag, Location, Link to scripts. cors_allowed_headers and cors_expose_headers add to those lists, and cors_allow_credentials = true sends Access-Control-Allow-Credentials with named origins; with "*" it is refused with a logged error, and a "*" header entry is dropped.
Security note: with credentials you MUST list explicit origins. allow_credentials(true) combined with allow_any_origin() does not give you credentialed wildcard CORS — there is no such thing, and asking for it gets you neither half.
Rustango answers such a request with Access-Control-Allow-Origin: * and no Access-Control-Allow-Credentials header, so the browser blocks the credentialed request. Older releases echoed the requesting origin back instead, which browsers do accept alongside credentials — that combination was a working hole, readable by any site the user happened to visit, and this page previously described it as something the browser would reject.
Use .allow_origins([...]) with the origins you actually serve. That path is unchanged and still sends Access-Control-Allow-Credentials: true.
Rate limiting requests
Rate limiting caps how many requests a client can make in a window of time. Use it to slow down brute-force logins, scraping, and abuse. You can limit per IP, per API key, or globally for one expensive endpoint.
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)));
When exhausted: 429 Too Many Requests with Retry-After header. Every successful response includes X-RateLimit-Limit + X-RateLimit-Remaining. The layer is a token bucket: capacity requests, refilled over refill_period. It keeps at most 100,000 buckets; change that with .max_buckets(n).
Behind a reverse proxy, name your proxies or
per_iplimits nothing useful.RateLimitLayer::per_ipkeys on the connecting socket, which behind a proxy is the proxy's address — so every client shares one bucket. One noisy client rate-limits everybody, and no individual attacker is ever limited.Mount
RealIpLayerand tell it which hops to believe: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"])?, // your ingress, not the internet );Order is load-bearing and fails silently. Layers apply outermost-last, so
real_ipmust be added afterrate_limitin order to run before it. Get it backwards and the limiter sees no resolved address and quietly keys on the socket again — it warns once when a forwarding header arrives without one.
trust_proxiesis not optional decoration.X-Forwarded-Foris set by whoever sends it. Without a declared proxy list the header is only a claim, so the limiter ignores it entirely and keys on the socket — deliberately. If it did otherwise, any client could mint a fresh bucket per request by varying a header, turning "the limit is too coarse" into "there is no limit".RealIp(the claim) is fine for logging; onlyTrustedRealIp, which appears when the peer matchestrust_proxies, keys a limiter.One rule for every IP reader. Rate limits, ViewSet throttles, login limits, auth signals and the access log all use the same address:
TrustedRealIp(readX-Forwarded-Forright to left, take the first hop not intrust_proxies), else the socket. None reads the leftmost hop: the client writes it.Name the addresses your ingress actually connects from. Trusting a range wider than that hands the bypass to anyone inside it.
Built-in login limits
Every built-in password login (admin, operator console, tenant admin, JWT /auth/login) goes through rustango::login_throttle before the user lookup. It checks, in order, a per-username lock (5 failures, 15 min) that counts unknown usernames the same as real ones, a per-IP limit (20 failed logins/min; IPv6 counts per /64) and a global limit per login (600 failed logins/min each for the admin, the operator console and every tenant). A successful login does not count against the limits. A username that signed in from an IP in the last 30 days skips a spent global limit from that IP, so a spray from many IPs cannot lock every user out; its per-IP limit and username lock still apply. Once the user row is found, the lock also follows the stored username, so spellings the database treats as equal (MySQL matches alicé to alice) share one lock. HTTP Basic and API keys have their own scope and only count failures. A refused login gets 429 with Retry-After, and the same answer whether or not the account exists.
Behind a reverse proxy, mount RealIpLayer with your proxies as shown above; a Cli app calls Cli::with_trusted_proxies(["127.0.0.1/32"])? or sets [server] trusted_proxies = ["127.0.0.1/32"] instead (code wins; an invalid entry stops boot). Without it every client has the proxy's address and shares one per-IP bucket; a warning is logged once when forwarding headers arrive.
Password hashing runs at most one job per CPU, shared by every login; HTTP Basic, API keys and agent secrets may use at most half the slots. A login that waits longer than hash_wait_ms (5 s) for a slot gets 503 with Retry-After, for known and unknown users alike.
Tune them under [auth]: login_ip_limit, login_ip_window_secs, login_global_limit, login_global_window_secs, lockout_threshold, lockout_duration_secs, hash_wait_ms. A value set in code (login_throttle::configure_shared, account_lockout::configure_shared, passwords::configure_hash_wait) wins over these keys. The per-IP and global buckets are per process unless you install login_throttle::configure_shared(LoginThrottle::with_cache(limits, cache)), which counts them in fixed windows in a shared cache (on a cache error both fall back to the process; a burst across a window edge can reach twice a limit; apps sharing one cache share the counters unless each uses its own ScopedCache); the first login logs a warning while they are per process. The username lock uses account_lockout::shared(), which you can back with a shared cache via account_lockout::configure_shared. Failures count in a fixed window (1 hour by default) that starts at the first failure, so an attacker gets lockout_threshold - 1 guesses per window without a lock; the per-IP and global limits bound the rest.
Sign-in field. Tenant users sign in with their username unless [auth] login_field = "email" (or Cli::login_field(LoginField::Email) / server::Builder::login_field, which win over the key). Email needs the sso feature, which adds the email column. The email matches ignoring case on every backend, on the tenant sign-in form (labelled "Email"), HTTP Basic and /auth/login ({"username": …} or {"email": …}), and the login lock above follows the stored email. Each user needs their own email: a user without one cannot sign in, and an email two users share ignoring case signs neither in. The server logs each tenant with such users at boot; it warns rather than refusing to start, so one tenant's data cannot take every tenant down. create-user --email sets it.
RateLimitLayer is process-local — it counts requests only within one running instance, which is fine if you run a single instance. If you run several instances (replicas) behind a load balancer, each would keep its own count, so the real limit multiplies. To share one count across all replicas, use rate_limit_cache::CacheRateLimitLayer, which delegates to any cache::Cache impl (pair with cache::RedisCache for a shared counter incremented atomically by Redis INCRBY):
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"),
);
It counts in fixed windows, so a burst across a window edge can reach twice the limit. A cache error lets the request through (fail open) and logs a warning.
Keying by a secret header is safe to share. When you
key_by(KeyBy::Header("authorization"))or"x-api-key", the cache-backed limiter hashes the header value before using it as a key, so a shared Redis never stores a live credential where someone reading keys could harvest it. Keying byKeyBy::IpneedsConnectInfo(or aRealIpLayerwithtrust_proxiesin front); without it the limiter warns once and falls back to a single shared bucket.
Allowing or blocking IPs
Lock down sensitive routes (like your admin) to a known set of IP addresses, or block specific abusers. An allowlist permits only the listed networks; a blocklist denies the listed ones.
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 supported. Cross-family safe (an IPv4 CIDR doesn't match IPv6 addresses). Returns 403 Forbidden on rejection.
Important: Rustango reads the client IP from ConnectInfo<SocketAddr>. Mount with:
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await?;
A request with no ConnectInfo is refused by allow_only and let through by block; change that with .allow_no_ip(bool). Behind a reverse proxy the peer is the proxy. Call .behind_trusted_proxy() to check the client IP that a RealIpLayer with trust_proxies resolved instead. The filter must sit inside that RealIpLayer (add .real_ip(...) after .ip_filter(...)), or it checks the proxy address and a blocklist lets everyone in. ip_filter never reads a forwarded header itself.
Protecting against CSRF
CSRF and token APIs. CSRF protects cookie-authenticated requests. A pure token / Bearer / JWT API isn't a CSRF target — don't apply the CSRF layer to it (or you'll 403 legitimate clients). For an SPA that does use cookies, read the
rustango_csrfcookie and echo it in theX-CSRF-Tokenheader.
CSRF (cross-site request forgery) is when another site tricks a logged-in user's browser into submitting a request to your app. The defense is a secret token on every form, which the middleware checks on every unsafe request. The CSRF middleware lives in rustango::forms::csrf (behind the csrf feature, which is turned on automatically by the admin feature):
use rustango::forms::csrf;
let app = Router::new()
.route("/contact", get(form).post(submit))
.layer(csrf::layer());
csrf::layer() takes the cookie's Secure flag from session::secure_cookies(): the boot policy ([security] secure_cookies, which the manage path sets to true by default), else Secure on the prod tier. The template_views CBVs mint their cookie through forms::csrf::ensure_token, which follows the same policy. csrf::with_config(CsrfConfig) uses CsrfConfig::secure instead (default true); on http://localhost call CsrfConfig::default().allow_insecure_for_dev(), or the browser drops the cookie and every POST gets 403. with_config also sets the cookie/header names and trusted_origins — extra origins allowed beside the request's own Host (.trust_origin("https://app.example.com"), *.example.com wildcards allowed). The Origin check runs even when it is empty: a foreign Origin gets 403, and over TLS so does a POST with no Origin. In templates, {{ csrf_token }} gives you the raw token and {{ csrf_input }} a ready-made hidden <input> — write it as {{ csrf_input | safe }}, because templates autoescape and without the filter the page renders a visible literal <input …> and the form carries no _csrf field, so every POST 403s. Both variables are only in context for the template_views CBVs or after you call forms::csrf::stamp_into_context yourself — a hand-rolled handler has neither. It uses the double-submit cookie pattern. GET, HEAD, OPTIONS and TRACE are safe, and so is QUERY unless require_csrf_on_query is set. On every other method the layer checks the X-CSRF-Token header (or the _csrf form field) against the rustango_csrf cookie; a mismatch returns 403 Forbidden.
Exempting collector endpoints. CsrfConfig::exempt_prefix("/path") (repeatable) skips CSRF enforcement for unsafe methods on requests whose path starts with the given prefix. This is for append-only, no-auth-state endpoints hit via navigator.sendBeacon — e.g. an analytics collector — which can't set an X-CSRF-Token header and, when the page is served from a CDN cache that strips Set-Cookie, may carry no CSRF cookie at all. Keep prefixes narrow and never exempt anything that reads or writes auth state.
With the mcp feature, Cli::with_csrf() and with_csrf_config() exempt the MCP endpoints themselves: {prefix}, {prefix}/token and {prefix}/oauth/token for the [mcp] prefix (default /mcp), nothing else under it. MCP clients send no CSRF cookie. The tenant router checks an agent token there; the single-database mcp::router(pool) checks none, so put your own auth in front of it. A prefix of / exempts nothing and logs a warning. The exemption applies whether or not an MCP router is mounted, and boot logs a warning naming the paths: keep cookie-auth routes off them.
The auto-admin. When you call .with_session_auth(...), CSRF is mounted on every admin mutation automatically — create, update, delete, bulk actions, audit cleanup — and every admin form renders its token for you. There is nothing to wire up and no way to opt out.
The condition is deliberate rather than incidental: CSRF defends credentials the browser attaches on its own, so it is the session cookie that makes it meaningful. An admin built without with_session_auth has no rustango-managed credential to forge with, and its mutations are reachable directly by anyone who can reach the route — that is an authentication gap, not a CSRF one, and CSRF would not narrow it. That holds only while nothing in front of it is a browser-attached credential. HTTP Basic (protect_with_basic_auth) or your own cookie auth is one, so cross-site POSTs ride it — and mounting csrf::layer() yourself does not fix it, because the admin renders form tokens only under with_session_auth, so every POST 403s. Use with_session_auth for a browser-facing admin.
Older versions of this paragraph claimed the protection was unconditional while the only guarded route was POST /login. Every other admin mutation accepted a cross-site POST riding the administrator's session — audit cleanup among them, so the same request class could erase its own trace.
Preventing XSS
XSS (cross-site scripting) happens when user input is rendered as HTML and runs as code in someone else's browser. The fix is to escape any user input before it reaches the page. Rustango handles this two ways:
1. Tera template auto-escape — Tera is Rustango's template engine. Every {{ var }} is HTML-escaped automatically in every template the framework builds, whatever its suffix. Build your own engine with template_extensions::html_tera() / html_tera_from_glob(glob) to get the same; a bare Tera::new escapes only .html, .htm and .xml. Plain-text email parts (EmailRenderer subject and .txt body) are the one place rendered raw. Use {{ var | safe }} to opt out — rare, and dangerous, so only do it for HTML you fully trust.
2. Manual escape helper — for when you build HTML in Rust code instead of a template:
use rustango::text::html_escape;
let safe = html_escape(user_input);
// "<script>" → "<script>"
// "a & b" → "a & b"
Replaces &, <, >, ", '. Suitable for HTML element content + double-quoted attributes.
For CSP-based XSS defense, see Setting security headers.
Preventing SQL injection
SQL injection happens when user input gets pasted straight into a SQL string and changes the query. Rustango's ORM stops this by design: it uses sqlx parameter binding everywhere — every value is sent as a bound placeholder ($1, $2, ... on PostgreSQL, ? on MySQL and SQLite), never glued into the query text. (sqlx is the underlying database library.) You can't accidentally concatenate user input into SQL through the ORM API.
There are two spots to stay careful about:
1. Table and column names in bulk_actions:
Placeholders protect values, but they can't carry an identifier (a table or column name), so those need their own guard. Bulk actions write through the ORM, so no raw name reaches SQL. A built-in action that sets a column looks it up in the model schema; a name the model does not have fails with BulkActionError::InvalidIdent. The selected keys are a PkSet, typed from the model's primary key: a key of the wrong type fails with InvalidPk, and one set holds at most 10,000 keys. This guard runs for you — you don't call it yourself.
2. The raw SQL escape hatch:
If you drop down to raw sqlx, bind every value as a parameter and
never paste an identifier (table or column name) that came from
user input into the query string — placeholders can't carry identifiers:
// ✅ 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?;
Authenticating users
Authentication is how you confirm who is making a request. Rustango ships three ready-made backends (Basic auth, API keys, and JWTs) and lets you write your own. You attach them to routes, and requests without a recognized credential get a 401.
Admin SSO. To let operators sign in to the admin with an external IdP (Google, Microsoft/Azure AD, GitHub, or any OpenID Connect provider) instead of a password, enable the
admin-ssofeature — see the SSO guide. Providers are managed from the admin UI as rows (multiple per surface; per-tenant, or a shared set across tenants), with the client secret encrypted at rest. It signs in the account linked to the IdP subject (email linking is opt-in per provider and never applies to superusers or staff; no auto-provisioning) and reuses the existing session.
Three ready-made 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
// No pool: the credential is checked against the tenant resolved from
// the request's own host. Taking one let a credential from one tenant
// authenticate on another's — fixed in 0.57.11.
The middleware tries each backend in order. The first one that succeeds wins; the first one that returns a hard error stops the chain. require_perm answers 401 without a user, 403 without the codename and 503 when the permission read fails. optional_auth(backends) resolves the user the same way but lets anonymous requests through. All three need the tenancy layer mounted outside them; without a TenantContext they fail closed with 500.
Writing your own backend
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
}
}
Hashing and checking passwords
Never store passwords as plain text. Hash them on signup with hash, and on login compare the attempt with verify. Use strength_score to reject weak passwords before you ever hash them.
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)?;
hash and verify block the thread for the whole hash. In async code use hash_async / verify_async, which run on the bounded hashing pool and return PasswordError::Busy when no slot frees up in time. After a successful login, needs_rehash(stored) tells you the hash is below today's cost; rehash_async gives the new one.
Hashing uses argon2id (the OWASP-recommended default since 2023 — it resists GPU cracking better than bcrypt). Hashes are stored in PHC-string format, so you can change parameters later without breaking old hashes.
The built-in strength check is intentionally minimal. For serious deployments, also check passwords against the HIBP / pwned-passwords API to reject ones known to be leaked.
Deep dive: Passwords — argon2id internals, automatic salting, timing-safe logins (
verify_dummy), and where the hash lives. Once authenticated, hand off to a Session (browser) or a JWT (API).
Issuing and refreshing JWTs
A JWT (JSON Web Token) is a signed token a client sends instead of logging in every time. JwtLifecycle handles the whole flow: a short-lived access token plus a longer-lived refresh token, with verify, refresh, and revoke built in.
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
These are also the defaults. JwtLifecycle::new panics when secret is shorter than 32 bytes, because a short key lets anyone mint tokens. issue_pair(user_id) issues a pair with no custom claims, issue_access_with a single access token, and verify_refresh checks a refresh token.
Issuing a token with custom claims (no DB lookup on 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" are the key/value facts you embed in the token (roles, tenant, and so on); the client sends them back and you read them without hitting the database. Reserved claim names (sub, exp, jti, typ) are used by the framework and can't appear in your custom claims — trying returns JwtIssueError::ReservedClaim.
Verifying a token
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();
Refreshing a token (keeps your custom claims)
let new_pair = jwt.refresh(&refresh_token).await
.ok_or(StatusCode::UNAUTHORIZED)?;
// new_pair has the same roles + scope + tenant
The old refresh token's JTI (its unique ID) is added to a blacklist so it can't be reused — this blocks replay attacks where a stolen old token is sent again. A refresh token is single use: of two refreshes of one token, even at the same time, the second gets None.
Refreshing with re-checked permissions
// 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)?;
Revoking a token
jwt.revoke(&access_token).await; // adds JTI to blacklist; false if the token isn't ours
jwt.revoke(&refresh_token).await;
The default in-memory blacklist (InMemoryJtiStore) cleans out expired entries on its own, but it lives in one process and forgets every revocation on restart. For multi-process deployments, install a shared, durable store via JwtLifecycle::new(secret).with_jti_store(store) — store is any Arc<dyn JtiStore>. Only InMemoryJtiStore ships, so you write the Redis- or DB-backed one yourself. Without a shared store, a token you revoke on one replica can still be replayed on another until it expires. All of these are async since v0.52 — verify_*, refresh* and revoke await the store, so a durable store can be a single conditional write instead of an eventually-consistent cache with a background flusher.
Deep dive: JWT auth API — the built-in
/api/auth/login|refresh|logout|merouter, sliding refresh, and the JTI revocation store. For a single self-managed token, see JWT (standalone). To gate browser/API routes by login, see access decorators.
Authenticating with API keys
API keys let scripts and services authenticate without a username, password, or session — handy for machine-to-machine access. You generate a key, show it to the user once, and store only its prefix and hash.
use rustango::api_keys::{generate_key_async, verify_key_async, split_token};
// Issuance
let (full_token, prefix, hash) = generate_key_async().await?;
// 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_async(secret, &row.hash).await? {
return Err(StatusCode::UNAUTHORIZED);
}
These keys work directly with the multi-tenancy ApiKeyBackend — both use the same {prefix}.{secret} format with argon2id-hashed secrets, so a key issued here authenticates there with no extra work.
Adding two-factor auth (TOTP)
TOTP is the 6-digit code from an authenticator app — a second factor on top of the password. You generate a secret per user, show it as a QR code to enroll, then verify the code they type on each login. It follows the 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");
}
Works with Google Authenticator, Authy, 1Password, Bitwarden, and other standard authenticator apps.
verify accepts the same code again until it expires. To make codes single use, call matched_step instead, store the step it returns, and accept a code only when its step is later than the stored one. The built-in admin login does this.
With the totp feature the bare admin has two-factor sign-in too. Each user enrolls at <admin>/account/totp, and a confirmed device is asked for at every login, SSO included. Turning it off takes a current code; for a lost device run manage reset-admin-totp <username> (reset-operator-totp for an operator). Enrolling, turning off and failed codes write audit rows.
The operator console has the same second factor, optional per operator or required for all; see Two-factor sign-in.
Recovery codes (one-time backup codes for when a user loses their phone) aren't shipped yet. The common pattern is to store 8–10 hashed codes per user and burn one each time it's used.
Sending signed URLs (magic links)
A signed URL carries a tamper-proof signature, so you can trust it without a session — perfect for magic-link logins, password resets, and time-limited download links. You sign a URL (optionally with an expiry) and verify it when the user clicks.
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 */ }
}
Common uses:
- Magic-link login (email a one-time URL instead of asking for password)
- Password reset confirmation
- Time-limited file downloads
- "Click here to verify your email" links
- Unsubscribe links (no auth needed; URL itself proves intent)
The signature is HMAC-SHA256 over the scheme, host, path and the query, sorted first so param order does not matter. Changing any of them, expires included, breaks it.
These links are not single use. verify checks only the signature and the expiry, so a link that leaks (a Referer header, browser history, a forwarded email) keeps working until it expires. For a link that signs someone in or changes data, use the verify_single_use variants in auth_flows, or delete the record after the first click.
Deep dive: Account flows builds password reset, email verification, and magic-link login on this signed-URL primitive.
Verifying incoming webhooks
A webhook is an HTTP callback another service sends you (a payment succeeded, a push happened). Always check its signature so you know it really came from that service and the body wasn't changed. verify_signature handles the common formats.
Stripe and Slack do not sign the body alone: they sign a timestamp joined to it, and that timestamp is what stops a replay. Build that string yourself and check the timestamp, as below. Stripe signs "{t}.{body}" with your whole whsec_… secret as the key; Slack signs "v0:{ts}:{body}" and sends v0=<hex>.
use axum::{body::Bytes, http::HeaderMap, http::StatusCode};
use rustango::webhook::{verify_signature, SignatureFormat};
/// Stripe sends `Stripe-Signature: t=<unix>,v1=<hex>[,v1=<hex>]`
/// and signs `"{t}.{body}"`, not the body alone.
fn verify_stripe(secret: &[u8], header: &str, body: &[u8], now: i64, tolerance: i64) -> bool {
let mut ts = None;
let mut sigs = Vec::new();
for part in header.split(',') {
match part.split_once('=') {
Some(("t", t)) => ts = Some(t),
Some(("v1", sig)) => sigs.push(sig),
_ => {}
}
}
let Some(ts) = ts else { return false };
let Ok(t) = ts.parse::<i64>() else {
return false;
};
if (now - t).abs() > tolerance {
return false; // stale or future-dated: a replay
}
let mut signed = format!("{ts}.").into_bytes();
signed.extend_from_slice(body);
sigs.iter()
.any(|sig| verify_signature(SignatureFormat::HexSha256, secret, &signed, sig))
}
async fn handle_stripe_webhook(headers: HeaderMap, body: Bytes) -> StatusCode {
let Ok(secret) = std::env::var("STRIPE_WEBHOOK_SECRET") else {
return StatusCode::INTERNAL_SERVER_ERROR;
};
let header = headers
.get("stripe-signature")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs() as i64);
if !verify_stripe(secret.as_bytes(), header, &body, now, 300) {
return StatusCode::UNAUTHORIZED;
}
// ... process the verified payload
StatusCode::OK
}
| Format | Signs | Used by |
|---|---|---|
HexSha256WithPrefix | body, sha256=<hex> | GitHub (X-Hub-Signature-256) |
HexSha256 | whatever bytes you pass, raw hex | Stripe v1= / Slack v0= values over the strings above |
Base64Sha256 | body, standard base64 | Shopify (X-Shopify-Hmac-Sha256) |
Webhooks from webhook_delivery
Rustango's own deliveries carry X-Webhook-Signature-V2, signed over the id, event, timestamp and body. Check it with verify_delivery, then drop an X-Webhook-Id you saw within max_age. Pick a max_age longer than jobs may queue.
use axum::{body::Bytes, http::HeaderMap, http::StatusCode};
use rustango::webhook::{verify_delivery, DeliveryHeaders, SignatureFormat};
fn check(secret: &[u8], headers: &HeaderMap, body: &Bytes) -> StatusCode {
let h = |name| headers.get(name).and_then(|v| v.to_str().ok()).unwrap_or("");
let signed = DeliveryHeaders::new(
h("x-webhook-id"),
h("x-webhook-event"),
h("x-webhook-timestamp"),
h("x-webhook-signature-v2"),
);
let max_age = std::time::Duration::from_secs(600);
if !verify_delivery(SignatureFormat::HexSha256WithPrefix, secret, &signed, body, max_age) {
return StatusCode::UNAUTHORIZED;
}
// ... drop a repeated id, then process
StatusCode::OK
}
The older X-Webhook-Signature covers the body alone and is still sent while receivers move to v2.
A delivery answered 408, 429 or 5xx, or one that fails in transport, is retried: 8 runs in all, waiting 1s and doubling. WebhookSubscription::max_attempts(n) and retry_backoff(base) change both for a subscription. A Retry-After on a 429 or 503, in seconds or as an HTTP date, makes the next retry wait at least that long, but never past two minutes after dispatch, so the signed timestamp stays fresh. Other 4xx answers are dead-lettered at once; retry_status_codes([...]) adds codes to retry. Retries reuse the first timestamp, so the receiver's max_age must cover the whole retry window.
The signature comparison is constant-time, meaning it always takes the same amount of time whether the guess is right or wrong. That stops timing attacks, where an attacker measures tiny response-time differences to guess the secret one character at a time.
Outbound calls to URLs that config can set (webhook delivery, Slack webhook_callback, OAuth2/OIDC discovery, token and userinfo) refuse loopback, private, link-local and metadata addresses and never follow redirects. To reach an IdP or Slack hook on your own network, list it in RUSTANGO_OUTBOUND_ALLOW (comma-separated hosts and CIDRs, e.g. 10.0.5.0/24,idp.internal); everything else stays refused. A host entry trusts whatever that name resolves to, so list only names you control. The list never applies to webhook delivery: a subscription opts in with allow_private_targets(true). Nor does it apply to a tenant's own SSO provider rows, which a tenant admin sets: those read RUSTANGO_TENANT_OUTBOUND_ALLOW (empty by default, so public addresses only).
These calls ignore HTTPS_PROXY/NO_PROXY. To send them through an egress proxy, set RUSTANGO_OUTBOUND_PROXY (e.g. http://proxy.internal:3128); the proxy itself may be private. The app still resolves and checks each target first, so it needs local DNS for them; a lookup failure refuses the call. The proxy resolves the name again, so it must refuse private and metadata destinations too: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, 169.254.0.0/16 (incl. 169.254.169.254, 169.254.170.2, 169.254.170.23), 100.64.0.0/10 (incl. 100.100.100.200), ::1, fc00::/7 (incl. fd00:ec2::254, fd00:ec2::23) and fe80::/10. Without a proxy, addresses are checked again at connect time.
Keeping secrets out of your logs
Logged URLs can leak passwords and tokens that show up in query strings. AccessLogLayer automatically masks known credential params before writing the log line (PII = personally identifiable information).
let app = router.access_log(AccessLogLayer::default());
By default a query param is replaced with [redacted] when its name holds a secret word, matched by word, so api_key, reset_token and X-Api-Key all count. The words include password, pass, pwd, token, jwt, secret, key, signature, auth, credential, cookie, session and otp, plus the OAuth names code, state and assertion.
Add to the list:
let layer = AccessLogLayer::default()
.redact_additional("session_id")
.redact_additional("private_key");
Replace the entire list:
let layer = AccessLogLayer::default()
.redact(vec!["only_this".into()]);
token, signature and code stay redacted even after .redact(...), because the framework puts credentials in them.
Note: this redacts query strings only. To scrub request bodies or headers, filter at the source with tracing::Subscriber.
Tracing requests across services
A request ID tags every log line for one request with the same ID, so you can follow that request across handlers — and across services. RequestIdLayer adds one to every request.
let app = router.request_id(RequestIdLayer::default());
It reuses an inbound X-Request-Id only from a proxy named in RealIpLayer::trust_proxies (or Cli::with_trusted_proxies), so chained services share one ID, and otherwise generates a fresh 22-char base64 ID. Inbound IDs must be at most 128 visible ASCII characters with no quote or backslash, which blocks header and log injection.
In handlers:
async fn handler(id: rustango::request_id::RequestId) -> String {
tracing::info!(req_id = %id.0, "handling /me");
format!("req {}", id.0)
}
To put the ID on every log line of the request, also mount tracing_layer::TracingLayer::new(): its request span has a request_id field that the layer fills. Without it the ID reaches only the response header.
In a zero-trust setup, where you don't trust IDs sent by upstream services, always make your own:
router.request_id(RequestIdLayer::always_generate());
Managing secrets
Never hard-code secrets (database passwords, API keys) in your source. Read them at runtime through the Secrets trait, which abstracts over where they actually live. (A "trait" is Rust's version of an interface.) The built-in EnvSecrets reads from environment variables.
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
To pull secrets from Vault, AWS Secrets Manager, or GCP Secret Manager, implement the trait yourself — it's just one async method, get. In tests use InMemorySecrets::new().with(&[("DB_PASSWORD", "x")]).
Auditing before you deploy
Before going to production, run the built-in audit. It catches common misconfigurations — like a missing or too-short signing secret — that you don't want to discover in production.
manage check --deploy
Always-on checks (run with or without --deploy):
- ✅ Models registered in inventory
- ✅ DB reachable (
SELECT 1) - ✅ Migrations on disk for registered models
Additional --deploy (production-hardening) checks:
- ✅
RUSTANGO_ENVisprodorproduction - ✅
RUSTANGO_SESSION_SECRETset and decoding to ≥ 32 bytes (the HMAC key for cookie + JWT signing —SECRET_KEYis not read by the framework), with no scaffolder placeholder left in. It is base64:openssl rand -base64 32gives you 44 characters. A 32-character secret is only 24 bytes and is refused — bycheck --deployand the runtime alike, which is the point: the check used to measure the encoded string and pass a value the app then rejected - ✅ A database is configured:
DATABASE_URL, orDB_USER+DB_NAME(and warns if it points at localhost) - ⚠️
RUSTANGO_APEX_DOMAIN/RUSTANGO_BINDsanity warnings for tenancy + non-loopback binding - ⚠️ Settings left at dev values in the prod tier:
[security] headers_preset,secure_cookies = false,hsts_max_age_secs = 0, empty or"*"allowed_hosts,"*"incors_allowed_origins, emptycsrf_trusted_origins, nosecure_ssl_redirect;[server] request_timeout_secsandmax_body_bytesunset; a low[auth] argon2_memory_kib; an[auth.jwt] access_ttl_secsover an hour;[mail] backendunset orconsole - ⚠️ An admin router with no authentication, a login lockout store that is per process, the database collation and time zone, models whose
required_db_vendororrequired_db_featuresthe database lacks, and SSO providers that would refuse every user
RUSTANGO_SECRET_KEY is a different key: it encrypts stored secrets such as SSO client secrets. When tenant provisioning is mounted, check --deploy warns if it is missing or shorter than 32 bytes.
Returns non-zero exit on any error-level finding (good for CI gates). Warnings don't trigger failure but show in output.
For checks beyond what ships, extend with custom code in your manage binary before forwarding to manage::run.
What's NOT yet shipped
A couple of end-to-end flows still need gluing together from the primitives below:
- Password reset + email verification — the token issue/verify helpers exist (
auth_flows::confirm_password_reset_pool, plus email-verification round-trips) and the email pipeline ships separately, but there's no single prebuilt view + email + validate cycle wired for you. - PII redaction of request body / headers in
access_log— field-level log redaction exists (see Keeping secrets out of your logs), but not automatic scrubbing of the request body or headers.
Already shipped (don't reach for a workaround):
- OAuth2 / OIDC social login —
oauth2::providersships Google, GitHub, Microsoft, GitLab, and Discord helpers (plusOAuth2Provider::from_discoveryfor any OIDC provider), andoauth2::router::oauth2_routermounts the login + callback routes; yourOnAuthSuccesshook finds the user byAuthSuccess::identity_key()and sets the session cookie. - Per-account lockout —
rustango::account_lockout::Lockout(cache-backed;is_locked/record_failure/clear, configurablemax_attempts+lockout_duration). - CSP report endpoint —
security_headers::csp_report_router(path)+SecurityHeadersLayer::csp_report_uri(uri). - Distributed rate limiting —
rate_limit_cache::CacheRateLimitLayer(see Rate limiting requests).
Until the unshipped flows land, glue them from the primitives above (signed_url::sign for password-reset tokens, the email pipeline for delivery, etc.).
See also
- Middleware — how the security layers attach, their order, and the full catalog.
- Authentication — passwords, sessions, JWTs, API keys, HMAC, auth backends, and account flows, each in depth.
- API conventions — the error-type and return-type rules behind these APIs.
