This version is in beta. Some features may change before release.

Security plugin

Automatic CSRF protection and a hardening-headers bundle. Mount it and you're done.

umbral-security gives every non-safe request automatic CSRF validation (signed double-submit) plus a modern security-header bundle. Mount it and you're done: the middleware mints the token before your handler runs, your templates receive it ambiently, and a missing or forged token on any POST / PUT / PATCH / DELETE returns 403. Your view code never touches CSRF.

Code
rust
App::builder()
.plugin(AuthPlugin::default())
.plugin(SecurityPlugin::new())
.build()?;

In an HTML form, emit the hidden input with {{ csrf_input }}, the {% csrf_token %} of umbral:

Code
html
<form method="post" action="/contact">
{{ csrf_input }}
<!-- fields... -->
</form>

For JavaScript or htmx writes, send the raw token as a header instead. One attribute on <body> covers every htmx request on the page:

Code
html
<body hx-headers='{"X-CSRF-Token": "{{ csrf_token }}"}'>
Info
Token-authenticated APIs carry no session cookie, so their writes would 403. Exempt them with the chainable shorthand — `SecurityPlugin::new().csrf_exempt(["/api"])` — or via `csrf_exempt_paths` on a full `SecurityConfig`. The shorthand appends, so it composes with `with_config` and repeated calls.

Seeing the token in your page source is expected

The CSRF token is not a secret. umbral uses double-submit: the same value lives in a non-HttpOnly cookie and in the page, and a write is accepted only when the two match. JavaScript has to be able to read it, which means anyone viewing source can too. What stops an attacker is the same-origin policy — a page on evil.example can neither read your cookie nor read the body of a response from your origin, so it cannot produce a matching pair. Tokens are also HMAC-signed with your app's secret_key by default (signed_csrf: true), so a cookie planted by a sibling subdomain can't forge a valid signature, and stale or unsigned cookies rotate automatically on the next safe request.

What would be a leak is a cache handing your rendered page — token, data and all — to somebody else. So any response to a personalised request (one carrying a session cookie, an Authorization, or a Proxy-Authorization header) ships Cache-Control: no-store, private. private bars shared caches like a CDN or a corporate proxy; no-store bars the browser's own disk and back/forward caches, which is what stops a logged-out user pressing Back into a rendered admin page.

Anonymous requests are left alone, so public pages stay CDN-cacheable, and a handler that sets its own Cache-Control always wins:

Code
rust
SecurityPlugin::with_config(SecurityConfig {
// Only if a cache you control already keys on the session cookie.
private_cache: false,
..Default::default()
})
Warning
Turning `private_cache` off without a cache that keys on the session cookie means a shared cache may store one user's authenticated page and serve it to the next.

Configuration is a struct, not a builder chain; flip exactly what you need:

Code
rust
SecurityPlugin::with_config(SecurityConfig {
hsts: true,
content_security_policy: Some("default-src 'self'".into()),
csrf_exempt_paths: vec!["/api".into()],
..Default::default()
})

Install

Code
bash
cargo add umbral-security

Production preset

The defaults are dev-safe: HSTS, CSP, and cross-origin isolation are off so local HTTP development works. A default deployment therefore ships with no XSS backstop (no CSP) and no SSL-stripping protection (no HSTS). Instead of hand-assembling those headers, use the one-call preset:

Code
rust
.plugin(SecurityPlugin::production_hardened())

It turns on HSTS (with preload), a strict CSP baseline (default-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'), Cross-Origin-Resource-Policy: same-origin, and marks the CSRF cookie Secure, while keeping every other secure default.

Warning
The strict CSP has no `'unsafe-inline'`, so inline `