umbral-security — CSRF protection and a configurable security-header bundle.
Django's CsrfViewMiddleware plus the SecurityMiddleware header bundle,
widened to the modern header set. Plug it into the app and every non-safe
request must carry a matching CSRF token; every response gets the hardening
headers you've enabled.
App::builder()
.plugin(AuthPlugin::new())
.plugin(SecurityPlugin::new()) // secure-but-dev-safe defaults
.build()
.await?;
Configuration is a struct, not a builder chain
Construct a [SecurityConfig] (every field has a secure, dev-safe default)
and flip exactly what you need — no long .with_x().with_y() chain:
SecurityPlugin::with_config(SecurityConfig {
hsts: true,
content_security_policy: Some("default-src 'self'".into()),
server_header: Some("umbral".into()),
request_body_limit: Some(2 * 1024 * 1024),
..Default::default()
})
SecurityPlugin::new() keeps the defaults; SecurityPlugin::with_hsts(true)
stays as a one-flag convenience.
CSRF
Signed double-submit cookie pattern, fully automatic (the Django
CsrfViewMiddleware + {% csrf_token %} split — see
docs/decisions/2026-06-10-automatic-csrf.md):
- The middleware is the only mint. On GET / HEAD / OPTIONS it mints a
token before the handler runs (first visit covered) and appends the
umbral_csrf_tokencookie to the response. The cookie is NOT HttpOnly: the page's JS reads it and copies it into a header on later writes. - Templates get the token for free. The token is scoped into
umbral::templates::CURRENT_CSRFaround every non-exempt request, so any rendered template can write{{ csrf_input }}(the full hidden input) or{{ csrf_token }}(raw value, forX-CSRF-Tokenheaders / htmxhx-headers). View code never touches CSRF. - Every POST / PUT / PATCH / DELETE must include the cookie AND a matching
X-CSRF-Tokenheader (JS path) orcsrf_token/__csrfform field (HTML-form path). A mismatch returns 403. On success the token stays in scope so a validation-error re-render still carries it into the form.
The token is a 32-byte CSPRNG value, hex-encoded. The CSRF cookie gains
Secure automatically under Environment::Prod (or force it with
[SecurityConfig::csrf_cookie_secure]).
Signed / session-bound CSRF ([SecurityConfig::signed_csrf])
Naive double-submit trusts the cookie: an attacker who can plant a cookie on
a sibling subdomain can forge a matching token. signed_csrf (default
on) makes the token <random>.<HMAC-SHA256(secret_key, random[.session])>
— a forged cookie can't carry a valid signature without the app
secret_key. Set [SecurityConfig::session_bind_cookie] to also fold the
session cookie's value into the signature so a token minted under one
session can't be replayed under another.
The flip to default-on is deploy-safe because the middleware rotates
any cookie token that can't pass signed-mode validation on the next safe
request (browsers holding pre-upgrade unsigned cookies converge instead of
403ing), and because no other mint exists: the admin prefers the ambient
middleware token and only self-mints when this plugin isn't mounted. With
no resolvable secret_key (tests, pre-App::build() renders) minting and
validation degrade to plain double-submit instead of locking writes out.
Opt back into plain double-submit with signed_csrf: false.
Headers
Enabled by default: X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin, X-XSS-Protection: 0 (modern guidance disables the legacy auditor), Cross-Origin-Opener-Policy: same-origin (matches Django 4.0+), and a Server: umbral header. Opt-in
(default off, each a field on [SecurityConfig]): Strict-Transport-Security,
Content-Security-Policy, Permissions-Policy, Cross-Origin-Resource-Policy,
Cross-Origin-Embedder-Policy. CSP and HSTS are off by default because a wrong
value breaks apps (HSTS bricks http:// dev; a strict CSP breaks the CDN-using
admin).
Server identity & tower-http knobs
[SecurityConfig::server_header] sets the Server header (prefer a bare
product name — a version is an information-disclosure tradeoff);
[SecurityConfig::hide_server_header] strips whatever the stack added.
[SecurityConfig::request_body_limit] caps the request body via tower-http's
RequestBodyLimitLayer (DoS hardening); [SecurityConfig::redact_sensitive_headers]
(default on) marks authorization / cookie / set-cookie sensitive so
they're redacted in tracing output.
Why this lives in Plugin::wrap_router
Layering middleware needs a tower::Layer value; the Plugin trait's
wrap_router(Router) -> Router lets each plugin layer its middleware with
the full axum / tower API. The app builder calls it in topological order so
security wraps everything declared before it.