This is the hub guide for "who is this request?" in Autumn: cookie-backed sessions, password hashing and password policy, login/logout, route protection, account lockout, remember-me, and active-session revocation.
Everything here is about proving identity. Deciding what an identified user is allowed to do is authorization; proving identity again before a dangerous action is step-up auth.
Adding a second factor — two-factor authentication (2FA) with a TOTP
authenticator app — is a flag on the generator rather than a separate
subsystem: autumn generate auth User --totp. The generator also scaffolds
passwordless sign-in (--passkeys, --magic-link), which replaces the
password rather than adding a factor on top of it — a different security
property, and not 2FA. See
Quick start.
This guide covers:
- What's in the box — the primitive behind each capability.
- Quick start:
autumn generate auth. - Sessions — the
Sessionextractor, cookie, and stores. - Passwords — hashing, verification, and the password policy.
- Login and logout — session fixation and non-enumeration.
- Protecting routes —
#[secured],RequireAuth,Auth<T>. - Account lockout — credential-stuffing defence.
- Remember me — rotating tokens with theft detection.
- Active sessions and revocation.
- Testing authenticated routes.
- Production checklist.
- Where to go next.
What's in the box
Autumn ships the primitives — a session store, password hashing, a password
policy, a route guard — and the generator that assembles them into a complete,
app-owned login flow. Nothing here is a black box: autumn generate auth writes
ordinary handlers into your src/routes/, and you edit them like any other code.
| Capability | Primitive | Config block |
|---|---|---|
| Cookie-backed sessions | autumn_web::session::{Session, SessionStore} | [session] |
| Password hashing | autumn_web::auth::{hash_password, verify_password} | — |
| Password policy | autumn_web::auth::validate_password | [auth.password] |
| Route guard | #[secured], RequireAuth, Auth<T> | [auth].session_key |
| Account lockout | generated login handler | [auth.lockout] |
| Remember me | autumn_web::auth::remember | [auth.remember] |
| Session tracking / revocation | generated *_sessions table | [auth.sessions] |
| Re-auth for sensitive actions | #[step_up] | [auth.step_up] |
| Social login | autumn generate auth --oauth … | [auth.oauth2.*] |
Quick start: autumn generate auth
autumn generate auth User
One command scaffolds the whole browser flow — a User model and migration,
route handlers, request-level tests, and documentation — as app-owned code:
| Method | Path | Auth |
|---|---|---|
GET/POST | /signup | Public |
GET/POST | /login | Public |
POST | /logout | Any |
GET | /account | Required + email-confirmed |
POST | /account/destroy | Required + step-up |
GET/POST | /reauth | Any |
GET/POST | /forgot-password | Public |
GET/POST | /reset-password | Public |
GET | /auth/confirm/{token} | Public |
GET/POST | /auth/confirm/resend | Public |
GET | /account/sessions | Required |
POST | /auth/admin/unlock | X-Admin-Secret header |
Optional factors compose on top, each off by default:
| Flag | Adds |
|---|---|
--oauth github,google | Redirect + callback handlers, oauth_identities table, the oauth2 feature — see OAuth2 / OIDC |
--totp | Two-factor authentication (2FA) with a TOTP authenticator app: enrollment + login-verify, encrypted-at-rest secrets, single-use recovery codes |
--passkeys | WebAuthn ceremony handlers, webauthn_credentials table, a passkey list/revoke surface |
--magic-link | /login/magic request → email → verify, single-use digest tokens, per-email cooldown |
The generator also writes docs/guide/authentication.md,
docs/guide/session-management.md, and docs/guide/gdpr-compliance.md into
your project, describing the code it just wrote (that project-local file is
distinct from this framework guide). Re-run with --force to regenerate
templates after an upgrade.
Prefer to hand-roll? The rest of this guide is the primitive-by-primitive tour.
Sessions
A session is a server-side key-value map keyed by an opaque id that travels in a
cookie. SessionLayer is installed for you by AppBuilder; handlers just ask
for the Session extractor (re-exported from the prelude):
use autumn_web::prelude::*;
#[get("/dashboard")]
async fn dashboard(session: Session) -> AutumnResult<String> {
let user_id = session
.get("user_id")
.await
.ok_or_else(|| AutumnError::unauthorized_msg("log in first"))?;
Ok(format!("Hello, {user_id}"))
}
The Session API
| Method | What it does |
|---|---|
get(key) / insert(key, value) / remove(key) | Read and write String values |
contains_key(key) | Membership test without cloning the value |
clear() | Drop all data, keep the id |
rotate_id() | Mint a new id for the same data — call on every privilege change |
destroy() | Drop the data and the server-side record, and expire the cookie |
id() | The current session id |
touch() | Force a save + Set-Cookie even if nothing was written |
Values are strings. Store a user id, a tenant id, a role — not a serialized object graph.
The layer only writes back when the session is dirty, i.e. something was
inserted, removed, cleared, rotated, or destroyed. That matters in one case: a
handler that only reads session.id() and hands that id to something which will
later identify the session never dirties it, so a first-time visitor's browser
never receives the cookie. Call session.touch() in that situation.
rotate_id() and destroy() both invalidate the old server-side record — on
save, the layer deletes the previous id from the store — so a captured cookie
cannot be replayed afterwards.
Cookie and backend configuration
[session]
backend = "memory" # "memory" | "redis"
cookie_name = "autumn.sid"
max_age_secs = 86400 # session TTL in seconds — 24 hours
secure = true # HTTPS-only; the dev profile sets false
http_only = true # invisible to JavaScript
same_site = "Lax"
path = "/"
[session.redis]
url = "redis://127.0.0.1:6379"
key_prefix = "autumn:sessions"
Every key has an AUTUMN_SESSION__* environment override
(AUTUMN_SESSION__BACKEND, AUTUMN_SESSION__COOKIE_NAME,
AUTUMN_SESSION__MAX_AGE_SECS, AUTUMN_SESSION__REDIS__URL, …). See
runtime config.
Backends:
memory(default) — process-local, lost on restart, not shared between replicas. Fine for dev and tests. Booting theprodprofile on it logs a warning; setsession.allow_memory_in_production = trueto acknowledge it deliberately (single-replica, restart-tolerant deployments).redis— survives restarts and is shared across replicas. Requires theredisCargo feature; without it, selecting the backend is a startup error rather than a silent downgrade.- Anything else — implement
SessionStore(three async methods:load,save,destroy) and install it withAppBuilder::with_session_store(…), which bypasses config-driven backend selection entirely.
If the store is unreachable mid-request the response is 503 Service Unavailable — sessions fail closed, they do not silently degrade to
"anonymous".
Cookie integrity
When a signing secret is configured, the session id in the cookie is HMAC-signed and a tampered cookie is rejected. The same is true in production whether or not you configure one, because the production profile fails fast without a secret.
Without a configured secret in dev/test, session cookies are not signed at
all. The router only threads signing keys into the session layer when a secret
is set or the profile is production; otherwise the cookie carries the raw
session id. In practice that is masked by the default memory store — its data
dies with the process, so a restart ends the session regardless. Point a dev
process at Redis or another persistent store, though, and an old cookie still
resolves across restarts, because there is no signature whose key changed. Set a
secret locally if you want dev to behave like production here.
Passwords
Hashing and verification
use autumn_web::auth::{hash_password, verify_password};
let digest = hash_password(&form.password).await?; // bcrypt, cost 12
let ok = verify_password(&form.password, &digest).await?;
Both run the bcrypt work on a blocking thread pool, so a login storm does not
stall the async runtime. verify_password also verifies against a dummy hash
when the stored value fails a cheap shape check — 60 characters starting with
$ — so an empty or obviously non-bcrypt column costs the same wall time as a
real mismatch instead of returning instantly and leaking that fact. The check is
shape-only: a 60-character $-prefixed string that is not a valid bcrypt
digest is passed straight to bcrypt::verify, which can reject it on parse
without doing cost-12 work. Corrupted or badly imported hashes can therefore
still be distinguished by timing; keep the uniform-timing guarantee for the
unknown-account path (below), which is the one an attacker probes.
hash_password hashes at a fixed cost of 12. [auth].bcrypt_cost is surfaced on
AuthConfig for applications that call bcrypt themselves; changing it does not
change what hash_password does.
Password policy
validate_password accumulates every applicable failure so the form can show
all of them at once, rather than making the user fix one problem per round-trip:
use autumn_web::auth::{BreachCheck, validate_password};
// `config_arc` clones the `Arc`, not the config behind it, so reading policy
// on a per-request path costs a refcount bump rather than a deep clone.
let config = state.config_arc();
let cfg = &config.auth.password;
let mut policy = cfg.policy();
if cfg.breach_check != BreachCheck::Off {
// The HIBP lookup needs an HTTP client; the default-off path never builds one.
policy = policy.with_client(autumn_web::http_client::Client::new());
}
// `context` holds strings the password must not resemble — email, username.
let validation = validate_password(&form.password, &policy, &[email.as_str()]).await;
if !validation.is_valid() {
return Ok(signup_page(cfg.min_length, &validation.messages().join("\n")));
}
Read config through state.config_arc() on anything that runs per request. It
hands back a shared Arc<AutumnConfig>, so the read is a refcount bump: borrow
the section you need off the handle (&config.auth.password), or clone just
that section if you need it owned.
state.config() is the per-boot accessor. It hands back an owned, independently
mutable snapshot, and paying for one means deep-cloning every config section
— 64 allocations and 1,384 bytes per call against a default config, more as your
config grows — to read a single field. On a request path that cost is repeated
on every request, and a handler that reads two or three sections pays it two or
three times over. Reach for it in on_startup hooks and one-shot setup, not in
handlers.
[auth.password]
min_length = 8 # counted in Unicode scalar values
reject_common = true # bundled 10k weak-password corpus, compiled into the binary
breach_check = "off" # "off" | "fail_open" | "fail_closed"
The similarity check tokenizes each context string on non-alphanumeric
boundaries ([email protected] → john, doe, example, com) and rejects
a password that equals a context string or contains any token of length ≥ 4.
Breach checking (HIBP) uses k-anonymity: only the first five hex characters
of the password's SHA-1 are sent to api.pwnedpasswords.com. The password and
its full hash never leave the process and are never logged. The two "on" modes
differ only in what happens when HIBP is unreachable — fail_open allows the
password, fail_closed rejects it. Start with fail_open: a transient HIBP
outage should not block legitimate sign-ups.
Render the policy into the form (minlength = state.config_arc().auth.password .min_length) so the client-side hint always matches what the handler enforces.
Login and logout
A login handler does three things: verify the credential, rotate the session id, then record the identity.
#[post("/login")]
pub async fn login(
session: Session,
mut db: Db,
Form(form): Form<LoginForm>,
) -> AutumnResult<Response> {
let email = form.email.trim().to_lowercase();
// Reject over-long inputs before any DB query or bcrypt work — they can
// never match a stored account and only waste CPU.
if email.len() > 254 || form.password.len() > 128 {
return Err(AutumnError::unauthorized_msg("Invalid email or password"));
}
let user: Option<User> = users::table
.filter(users::email.eq(&email))
.select(User::as_select())
.first(&mut *db)
.await
.optional()?;
let invalid = || AutumnError::unauthorized_msg("Invalid email or password");
let user = match user {
Some(u) => u,
None => {
// Always run a verification so response time is constant whether or
// not the address is registered.
let _ = verify_password(&form.password, DUMMY_HASH).await;
return Err(invalid());
}
};
if !verify_password(&form.password, &user.password_hash).await? {
return Err(invalid());
}
session.rotate_id().await; // session fixation
session.insert("user_id", user.id.to_string()).await; // the identity claim
session.insert("role", &user.role).await;
Ok(Redirect::to("/dashboard").into_response())
}
Three properties are worth naming explicitly:
-
Session fixation. An attacker who can plant a known session id in a victim's browser inherits their session the moment they log in — unless the id changes at that boundary.
rotate_id()on login (and on password reset, and after any privilege elevation) closes that window; the old server-side record is deleted on save. -
Non-enumeration. Wrong password, unknown address, unconfirmed account, and locked account all return the same status and body. The dummy-hash verification removes the dominant timing signal — without it, an unknown address skips bcrypt entirely and returns in a fraction of the time, which is a free "this email is not registered" oracle.
It narrows the gap rather than closing it. A wrong password against a real account still writes a
failed_attemptsupdate, and a successful or confirmed login writes a counter reset, where an unknown address returns straight after the dummy verify with no write at all. That residual difference is a DB round-trip, not a bcrypt cost, but it is measurable in aggregate — so pair it with rate limiting on/loginrather than treating the timing as truly uniform. -
Bounded work. Cap input lengths before touching the database or bcrypt.
Logout tears the session down and, if you issue them, revokes the device's remember chain first:
#[post("/logout")]
pub async fn logout(
session: Session,
State(state): State<AppState>,
mut db: Db,
headers: HeaderMap,
) -> AutumnResult<Response> {
let config = state.config_arc();
let remember_cfg = &config.auth.remember;
// Drop this device's tracking row (best-effort — the device must sign out
// even if the row delete hiccups), then revoke its remember chain. Hold
// that second result: the session teardown below must run either way, or
// skipping it is the classic bug — the session dies, the remember cookie
// survives, and the next request logs the browser straight back in.
let _ = untrack_current_session(&mut db, &session).await;
let revoke_result = revoke_remember_from_cookie(&mut db, remember_cfg, &headers).await;
session.clear().await;
session.rotate_id().await; // old cookie can no longer be replayed
// Fail the logout if the remember chain survived — it is a long-lived
// credential, so reporting success would be false. Still clear the
// cookie on THIS browser even on failure: otherwise it keeps presenting
// a still-valid remember cookie, and once the database recovers,
// `remember_me` would silently re-establish a session on the very next
// request, undoing this logout.
if let Err(error) = revoke_result {
let mut response = error.into_response();
append_set_cookie(&mut response, &build_remember_clear_cookie(remember_cfg));
return Ok(response);
}
let mut response = Redirect::to("/").into_response();
append_set_cookie(&mut response, &build_remember_clear_cookie(remember_cfg));
Ok(response)
}
clear() + rotate_id() keeps a fresh session available to carry a one-shot
flash message to the next page while still destroying the old
record. Use destroy() when you also want the cookie expired outright.
If you issue neither remember cookies nor tracking rows, the two helper calls
and the Set-Cookie drop out and logout really is just clear() +
rotate_id(). Add them back the moment you turn remember-me on.
The session teardown is unconditional; the remember chain is not
best-effort. The tracking-row delete (untrack_current_session) still
swallows its error (let _ = …) — losing that row only drops a device from
the account's device list, so a hiccup there is not worth failing logout over.
The remember-chain delete is different: it is a long-lived bearer credential,
so revoke_remember_from_cookie returns its error instead. logout holds
that result, tears the session down regardless, and only then branches on
it — so a failed DELETE still fails the whole request (the browser sees an
error, not a redirect), but the session is destroyed either way. The error
response still carries the remember-clear Set-Cookie: without it, this
browser would keep presenting its (still valid, since the delete failed)
remember cookie, and the next request after the database recovers would let
remember_me quietly log it back in. If your threat model needs the
remember-chain delete to succeed before you consider the account safe, queue
a durable job to retry it after this response, rather than
treating the request's own delete attempt as final.
Working code: examples/saas/src/routes/auth.rs
(signup with policy enforcement, submit tokens, and
remember-me) and
examples/reddit-clone/src/routes/auth.rs
(signup + login inside a transaction, with events and flash).
Passwordless sign-in: magic link and passkey login
Signing a user in without a password. Both mechanisms are flags on
autumn generate auth rather than separate
subsystems, and both replace the password rather than adding a factor on top
of it — a different security property from two-factor authentication (2FA),
which keeps the password and requires a second proof as well. Each composes with
the others on the same model.
| Flag | Mechanism | Routes it generates |
|---|---|---|
--magic-link | A single-use sign-in link emailed to the address | GET/POST /login/magic, GET/POST /login/magic/verify |
--passkeys | WebAuthn — platform authenticators (Touch ID, Face ID, Windows Hello) and FIDO2 security keys | /passkeys/register, /passkeys/login, /passkeys, /passkeys/revoke |
The generator writes the handlers, the migration and the templates, and
describes what it wrote into your project: --magic-link appends a
passwordless section to your project-local docs/guide/authentication.md, and
--passkeys writes docs/guide/passkeys.md. Those describe your generated
code. The settings below are the framework's: they are what the generated
handlers read at runtime, so they apply whether you have run the generator yet
or are still deciding whether the defaults suit you.
Magic link expiry (TTL) and per-email cooldown
[auth.magic_link]
ttl_minutes = 15 # one-time link lifetime, in minutes
email_cooldown_secs = 60 # per-email re-mint window, in seconds
Both keys are optional; omitting the section applies these defaults. Each has an
AUTUMN_AUTH__MAGIC_LINK__* environment override
(AUTUMN_AUTH__MAGIC_LINK__TTL_MINUTES,
AUTUMN_AUTH__MAGIC_LINK__EMAIL_COOLDOWN_SECS) — see
runtime config.
ttl_minutes(default15) — a magic link is a bearer credential, so the expiry window bounds the blast radius of one that leaks, via a forwarded mail or a shared inbox. Keep it at15or less; raise it only against that tradeoff. A negative value fails deserialization rather than minting already-expired links.email_cooldown_secs(default60) —POST /login/magicskips minting a fresh token when an unexpired, unconsumed one was already issued for that address inside the window, which throttles email-bombing a single address even from rotating IPs. The per-IP limit is separate, enforced by#[throttle]on the route.
Sending the link needs a configured mailer — see Mail.
Passkey (WebAuthn) relying-party settings: rp_id and rp_origin
Passkeys need the webauthn Cargo feature and a relying party — the domain
the credential is bound to. rp_id and rp_origin both default to the empty
string, and the passkey routes return 500 with "WebAuthn is not configured"
until you set them, so this is required configuration rather than tuning:
[auth.webauthn]
rp_id = "example.com" # domain only — no scheme, no port
rp_name = "My App" # shown in the authenticator's dialog
rp_origin = "https://example.com" # full origin, including the scheme
For local development, the origin carries the port and the scheme is http:
[auth.webauthn]
rp_id = "localhost"
rp_name = "My App (dev)"
rp_origin = "http://localhost:3000"
rp_idmust be the origin's own domain, or a registrable domain suffix of it. For an origin ofhttps://app.example.com, bothapp.example.comandexample.comare valid, and the choice sets the credential's scope:app.example.combinds it to that host,example.comalso covers sibling subdomains. Pick the narrowest one that covers every origin you sign in from. A public suffix is not a registrable domain, socomis never a validrp_id.rp_originmust parse as a URL and must be an origin thatrp_idcovers.WebauthnBuilderrejects a mismatch, so a wrong pair surfaces as a500from the passkey routes rather than as a ceremony that fails in the browser.- Changing
rp_idinvalidates every credential already registered under the old value: a passkey is bound to the relying party it was created for. Treat it as a migration, not a config edit. rp_name(default"My App") is display text only, and safe to change.
Binding is the security property, not a detail: a credential registered against
example.com cannot authenticate against an attacker's domain even if the
credential bytes are stolen.
Protecting routes
#[secured]
#[get("/dashboard")]
#[secured]
async fn dashboard() -> AutumnResult<Markup> { /* … */ }
#[get("/admin")]
#[secured("admin")] // 403 unless session["role"] == "admin"
async fn admin_panel() -> AutumnResult<Markup> { /* … */ }
#[post("/admin/purge")]
#[secured("admin", "moderator")] // any of the listed roles
async fn purge() -> AutumnResult<Markup> { /* … */ }
The macro injects hidden Session and AppState extractors and a check at the
top of the handler body. It resolves the identity key through
AppState::auth_session_key(), so [auth].session_key (default "user_id")
governs #[secured], #[authorize], #[step_up], and the generated handlers
uniformly — change it in one place.
Missing key → 401 Unauthorized. Present but wrong role → 403 Forbidden.
Two side effects worth knowing: a passing check publishes the user as the
request's current actor — so repository and audit writes
auto-attribute — and tags the request-scoped log context with user_id, so
every subsequent log line carries it. When a request is already authenticated by
a stronger principal (a bearer API token, via RequireApiToken), the token
principal wins and is not clobbered by the session user.
For token-authenticated routes, #[secured(scopes = ["posts:write"])] is
default-deny: every required scope must be present in the presenting token's
grants.
See macro transparency for the exact expansion.
Issuing, listing, rotating and revoking API tokens
The scopes in #[secured(scopes = [...])] above come from the bearer token the
client presents, and that token is minted by the CLI rather than by a signup
flow. Manage them with autumn token — run any of these with --help for the
full argument list.
This lifecycle is PostgreSQL-only. Every autumn token subcommand shells
out to psql, and DbApiTokenStore is backed by a
Postgres pool. On a SQLite app neither is
available: use InMemoryApiTokenStore for local work, or implement the
ApiTokenStore trait against your own table and
manage tokens through it.
# `issue` and `rotate` print the new token on stdout — and only its hash is
# stored, so capture it now or it is unrecoverable. (The "✓ …" confirmation
# goes to stderr, so it stays out of the captured value.)
TOKEN=$(autumn token issue service:ci --name ci --scope posts:write)
autumn token list service:ci # name, scopes, expiry, last-used, revoked — never the secret
# Then EITHER rotate — the old token stops working and this is the new one:
TOKEN=$(autumn token rotate "$TOKEN")
# …OR revoke, to stop it working with no replacement:
autumn token revoke "$TOKEN"
rotate and revoke are alternatives, not steps. Rotating already revokes the
token you passed it, so running revoke "$TOKEN" afterwards without
re-capturing would retire a token that is already dead and leave the live
replacement in the database with its secret lost.
--expires-at <ISO-8601> makes a token expire; omit it for a non-expiring one.
On Postgres, these commands read and write the managed api_tokens table, so
they reach your app only when it mounts DbApiTokenStore and has that table —
pass API_TOKEN_MIGRATIONS to .migrations(), or run autumn migrate. An app
wired to InMemoryApiTokenStore keeps its tokens in the process and seeds them
in code: a token issued by the CLI is invisible to it, and verification answers
401.
To revoke a leaked API token, run autumn token revoke "$TOKEN": it sets
revoked_at, and RequireApiToken answers 401 for every later request
presenting it.
To rotate an API token — a CI credential that must keep working — run
autumn token rotate "$TOKEN" instead. It revokes the old row and inserts a
new one carrying the same principal, name, scopes and expiry, then prints the
new secret. The replacement is a distinct token: new id, new created_at, and
no last_used_at until it is used. The retired row stays in the table with
revoked_at set, so autumn token list shows both — expect one live row and
one revoked row per rotation.
Nothing on the row survives a rotation as an identifier, and name is not a
substitute: the column is TEXT NOT NULL DEFAULT '' with no unique
constraint, and --name defaults to the empty string, so it is a label that
several tokens — including every unnamed one — can share. Tooling should
filter on revoked_at IS NULL to find the live token rather than treating
either the id or the name as a stable handle across rotations.
In Rust, the same four operations are
issue_scoped_api_token, list_api_tokens, rotate_api_token and
revoke_api_token — one per CLI subcommand.
issue_scoped_api_token takes its name, scopes and expiry as an
IssueTokenSpec.
RequireAuth and Auth<T>
#[secured] is per-handler. To gate a whole subtree, layer RequireAuth, which
rejects requests lacking the session key before they reach any handler:
use autumn_web::auth::RequireAuth;
let admin = admin_router.layer(RequireAuth::new("user_id"));
Auth<T> extracts a fully-loaded user that middleware has already placed in
request extensions, and returns 401 when it is absent:
use autumn_web::auth::Auth;
#[get("/profile")]
async fn profile(Auth(user): Auth<CurrentUser>) -> String {
format!("Hello, {}!", user.name)
}
#[secured] answers "is anyone logged in?"; Auth<T> hands you the row.
Neither decides whether that user may touch this record — that is
#[authorize] and Policy.
Account lockout
IP rate limiting does not stop credential stuffing that rotates source IPs. The generated login handler therefore also counts failures per account:
-
Each failed login increments
failed_attemptson the account row. -
At
threshold,locked_atis stamped. Atracing::warn!fires withevent = "account_locked", a salted SHA-256 account digest truncated to 8 bytes, and an IP prefix (IPv4 /24, IPv6 /64). Only the request that setslocked_atlogs, so each lock logs once, also when many requests fail at the same time. The digest and prefix let you correlate log lines without a raw account id.Set the salt. The digest is salted from the app's signing secret (
AUTUMN_SECURITY__SIGNING_SECRET). Production always has this set — see that guide's production requirements — so the digest is unrecoverable there. Dev and test may run with no secret configured; the digest then falls back to a compiled-in constant salt and logs a warning, but that only affects those local, process-only logs. -
While locked, every attempt — including the correct password — returns the same response as a wrong password, so the endpoint never reveals which accounts are locked.
-
A successful login clears the counter and the lock; the account auto-unlocks on the first successful login after the cool-off elapses.
[auth.lockout]
enabled = true # false → disable entirely (external policy in place)
threshold = 10 # consecutive failures before lockout; 0 also disables
window_secs = 60 # reserved for future sliding-window counting
cooloff_secs = 900 # 15 minutes
Each maps to an env override (AUTUMN_AUTH__LOCKOUT__THRESHOLD, …). Lockout
state is columns on the account row, so it lives in whichever database the app
is configured against and every replica sharing that database agrees. On a
SQLite deployment where each replica has its own
file, the counters are per-replica and the lockout threshold is effectively
multiplied by the replica count.
window_secs is not yet enforced — failures currently accumulate since the last
successful login rather than over a sliding window.
For operator recovery the generator emits POST /auth/admin/unlock, guarded by
an X-Admin-Secret header matched against AUTUMN_ADMIN_SECRET. Set that to a
strong random value and put network-level controls in front of the route; if the
variable is unset the endpoint always refuses.
Complementary, not redundant: pair lockout with
#[throttle] on /login and
CAPTCHA for the volumetric side of the same attack.
Remember me
A "keep me signed in" checkbox that outlives the session cookie is a long-lived bearer credential. Autumn implements the Jaspan rotating-token scheme (the model Spring Security uses) so a stolen cookie is detectable and self-limiting.
A credential is a (series, token) pair carried as series:token in a
separate cookie:
seriesis stable for one device's login chain — it is the database lookup key.tokenrotates on every use. Only its SHA-256 hash is ever stored.
On each request presenting the cookie, look up the record by series and call the pure decision function:
use autumn_web::auth::{RememberDecision, default_rotation_grace, evaluate_remember};
match evaluate_remember(&presented_token, record.as_ref(), now, default_rotation_grace()) {
RememberDecision::Rotate => { /* mint a new token, store its hash, extend expiry, re-cookie, log in */ }
RememberDecision::Accept => { /* previous token inside the grace window: log in, do not rotate */ }
RememberDecision::Theft => { /* delete the whole chain for this series; stay unauthenticated */ }
RememberDecision::Reject => { /* unknown or expired series: clear the cookie */ }
}
Theft is the point of the scheme: replaying a rotated-out token for a known
series can only mean two parties hold the chain, so the whole series is deleted
and the cookie cleared — neither party can use remember-me to authenticate
again. The 60-second rotation grace (DEFAULT_ROTATION_GRACE_SECS) keeps
concurrent requests that raced a rotation from false-firing that alarm.
Scope that precisely: Theft revokes the remember chain, not existing
logins. The generated middleware deletes the series and clears the cookie on
that one unauthenticated request; it does not destroy session records or delete
tracking rows, and it skips remember
processing entirely for a request that already carries a session identity. A
victim and an attacker who each hold a live session cookie both stay logged in
until those sessions end. If a theft signal should log everyone out, pair it
with revoke_all_sessions on that account.
[auth.remember]
enabled = true # issue remember cookies on login
duration_secs = 2592000 # 30 days; also the sliding expiry on rotation
cookie_name = "autumn.remember"
Because evaluate_remember is pure and deterministic, the security-critical
decision is unit-testable against fixed timestamps with no I/O.
Rules of thumb: mint a chain only on explicit opt-in; revoke this device's chain in the logout handler before tearing down the session, or a stolen cookie re-establishes the login; and treat a remember-authenticated request as weakly authenticated — send it through step-up before anything sensitive.
End-to-end wiring: examples/saas/src/remember.rs.
Active sessions and revocation
Cookie expiry alone cannot answer "sign me out of that other laptop". The
generated auth stack persists one row per login, keyed by the digest of the
session id, holding the user, login IP, parsed user-agent, an optional device
label, created_at, and last_seen_at.
Revoking a device deletes its row. That deletion is enforced only where a
handler looks the row up — require_tracked_session(&session, &mut db, &state), which loads the row by digest and, when it is gone, destroys the
cookie session and rejects with 401. The generated auth routes call it; so
must yours.
This is the part to get right, because the failure mode is silent:
// Enforces revocation — the row is checked on every request.
#[get("/dashboard")]
async fn dashboard(session: Session, mut db: Db, State(state): State<AppState>)
-> AutumnResult<Markup>
{
let user = require_tracked_session(&session, &mut db, &state).await?;
// …
}
// Does NOT enforce revocation. `#[secured]` checks only that the session
// carries the auth key, so a revoked device keeps reaching this route until it
// hits a handler that does the lookup.
#[get("/dashboard")]
#[secured]
async fn dashboard() -> AutumnResult<Markup> { /* … */ }
#[secured] and RequireAuth answer "is there a session key?", not "is this
session still authorized to exist" — revocation deletes the tracking row, not
the record in the session store. If revocation must hold across your whole app,
call require_tracked_session in every authenticated handler, or wrap the check
in middleware layered over the authenticated subtree so no
route can forget it.
last_seen_at is refreshed at most once per window, bounding a busy session to
one UPDATE per window rather than one per request. The raw session id never
reaches the database, so a database leak cannot be replayed as a cookie.
GET /account/sessions renders the device list with per-row revoke, a device
label form, and "sign out everywhere else" (htmx in-place swaps, with plain form
posts as the no-JavaScript fallback).
[auth.sessions]
revoke_on_credential_change = true # password change/reset, TOTP enroll/disable, passkey add/remove
last_seen_update_secs = 60
Leave revoke_on_credential_change on unless an external policy handles it: the
standard response to credential theft is to invalidate every other session.
The stored IP and user-agent are personal data. Delete rows on logout,
revocation, and account deletion; scrub stale rows on a schedule aligned with
session.max_age_secs; truncate IPs if full addresses are more than you need;
and include the table in your GDPR export.
Impersonation ("log in as this user")
Support eventually needs to see what a customer sees. Doing it by hand —
session.insert("user_id", target) — quietly breaks the audit trail: from that
point on every version row and audit event claims the customer did it.
autumn_web::auth::impersonation is the primitive that keeps the real operator
on the record.
Opt in first — it is default-deny, so an app without a registered gate
refuses every attempt with 403:
use autumn_web::auth::impersonation::ImpersonationGate;
autumn_web::app()
.state_initializer(|state| {
state.insert_extension(ImpersonationGate::allow_roles(["admin"]));
})
Then begin and end it from your own routes:
use autumn_web::auth::impersonation;
#[post("/support/impersonate/{user_id}")]
#[secured("admin")]
async fn start(
State(state): State<AppState>,
session: Session,
Path(user_id): Path<String>,
) -> AutumnResult<Redirect> {
impersonation::begin_impersonation(&state, &session, user_id).await?;
Ok(Redirect::to("/"))
}
#[post("/support/stop-impersonating")]
async fn stop(State(state): State<AppState>, session: Session) -> AutumnResult<Redirect> {
impersonation::end_impersonation(&state, &session).await?;
Ok(Redirect::to("/"))
}
What the framework guarantees while impersonation is active:
| Question | Answer |
|---|---|
Who do #[secured], RequireAuth and PolicyContext resolve? | the impersonated user |
Who do audit events and #[repository(versioned)] rows record? | the real impersonator — for versioned rows and the audit events the framework emits. An AuditEvent you build by hand records whatever actor_id you pass it, so pass impersonation::audit_actor_id(&session, &user).await or Current::actor() there |
Who does impersonation::impersonator_id(&state, &session) return? | the real impersonator |
| Session id | rotated on begin and end |
| Audit | one event on begin, one on end, each with actor_id = the impersonator and target_resource_id = the target |
Auth<T> is not in that first row: it is populated by your own loader
middleware from request extensions, so it follows the impersonated user only if
that loader reads the auth session key. The same caveat applies to any identity
key your app writes alongside user_id at login (a generated {model}_id /
{model}_email, a tenant_id): impersonation swaps the configured auth session
key. To swap more keys, return them from
ImpersonationPolicy::target_session_values. end_impersonation restores the
operator's values.
fn target_session_values<'a>(
&'a self,
ctx: &'a PolicyContext,
target: &'a ImpersonationTarget,
) -> BoxFuture<'a, Vec<(String, Option<String>)>> {
Box::pin(async move {
// Read the email from your own user store. `None` removes the key.
vec![("user_email".to_owned(), lookup_email(ctx, target).await)]
})
}
RequireAuth publishes the log context's user_id as the effective user,
so log lines follow the target. RateLimitPrincipal is the operator, so an
operator spends their own throttle budget. They cannot lock the customer out.
Read the state with the Impersonation extractor when you just want to branch
on it:
use autumn_web::auth::impersonation::Impersonation;
#[get("/")]
async fn home(impersonation: Impersonation) -> Markup {
match impersonation.state() {
Some(active) => /* show a banner naming active.impersonator_id */,
None => /* normal page */,
}
}
And what it refuses:
-
No nesting. Starting a second hop while impersonating is a
409, so it cannot be chained to escalate. -
No self-impersonation, and no blank target.
-
No unaudited swap.
begin_impersonationreturns500— leaving the session untouched — if the audit write fails or if the app has no audit sink installed at all (audit::write_from_stateis a silent no-op without one, so a missing sink would otherwise mean a swap with no record). Register one withAppBuilder::with_audit_sink(TracingAuditSink)at minimum before enabling impersonation. Ending is the opposite trade-off: a sink failure is logged, but the revert still happens. -
No client-chosen role. The impersonated session's role comes from
ImpersonationPolicy::target_role, resolved server-side; it defaults to no role rather than inheriting the admin's. -
No laundering past step-up.
last_strong_auth_atis a bare timestamp with no identity bound to it, so begin stashes and drops the operator's claim rather than carrying it over — otherwise a#[step_up]route could run a destructive action on the target's account on the strength of the operator's re-authentication. Ending restores the operator's own claim. The practical consequence: sensitive, step-up-gated actions cannot be performed while impersonating. -
No self-destructive configuration.
auth.session_keymust not be one of the keys impersonation reserves for its own bookkeeping (impersonator_id,impersonated_id,impersonation_session_id,impersonator_role,impersonator_last_strong_auth_at,impersonator_identity_keys,role) — the swap would clobber its own record — nor the live step-up claimlast_strong_auth_at, which the swap stashes aside; a collision there would let a numeric target id satisfy step-up with no reauthentication. Both directions refuse the misconfiguration, and registering the gate logs it at startup. Check it yourself withimpersonation::is_reserved_session_key. -
No inherited record. The record is bound both to which user it describes and to the session generation that created it. Either a different effective user, or a session-id rotation — which every login must perform anyway, to prevent fixation — retires it: it stops counting for attribution and the revert route refuses it, instead of handing whoever comes next the operator's identity and role. The generation binding is what covers the case an id check alone misses: the impersonated customer signing in as themselves on the same browser. Belt and braces: call
impersonation::clear(&session)from your own login / magic-link / passkey promotion too, so the record is gone outright rather than merely inert.
Session-id rotation
begin_impersonation and end_impersonation rotate the session id. Any state
that is keyed by the id must follow the new id. Register a rotation hook for
that state:
app.on_session_rotation(|rotation| async move {
// Move your rows from rotation.old_id to rotation.new_id.
Ok(())
})
The hook runs after the new session is stored. It sees one event per request,
from the first old id to the last new id. earlier_ids lists every id in
between. It does not run for a destroyed
session. A hook error is logged and does not change the response.
autumn generate auth registers a hook that re-points the tracked-session row.
Impersonation therefore works in apps with [auth.sessions] tracking.
Two parallel requests that rotate the same session each publish their own event. Only the first finds the row. Do not rotate one session from parallel requests.
A rotation that does not change the signed-in user retires the impersonation
record. For a rotation you trust (for example an idle-rotation policy), call
impersonation::rebind(&state, &session) right after rotate_id(). It returns
true when the record was kept. Do not call it from a login flow. Call
impersonation::clear there.
Tenancy is yours to enforce — the framework checks none, and allow_roles does
not look at the target at all, so it will happily impersonate any string
including another admin or a user in a different tenant. For anything
multi-tenant, implement ImpersonationPolicy and consult ctx (which carries
the session and the DB pool) to confirm the target exists and is in the caller's
tenant. begin_impersonation trims the target id before the policy sees it,
so the id you authorize is exactly the id that lands in the session.
use autumn_web::auth::impersonation::{ImpersonationPolicy, ImpersonationTarget};
use autumn_web::authorization::{BoxFuture, PolicyContext};
struct SupportDesk;
impl ImpersonationPolicy for SupportDesk {
fn can_impersonate<'a>(
&'a self,
ctx: &'a PolicyContext,
target: &'a ImpersonationTarget,
) -> BoxFuture<'a, bool> {
Box::pin(async move { ctx.has_role("support") && same_tenant(ctx, target).await })
}
fn target_role<'a>(
&'a self,
_ctx: &'a PolicyContext,
target: &'a ImpersonationTarget,
) -> BoxFuture<'a, Option<String>> {
Box::pin(async move { lookup_role(target.user_id()).await })
}
}
Session-based auth only: an API-token principal has no session to swap, so the bearer-token path is untouched.
autumn-admin-plugin ships the whole flow — routes, a persistent "Viewing as …
— Stop impersonating" banner, and one-click revert — behind
AdminPlugin::with_impersonation(gate). See the admin guide.
Testing authenticated routes
The test client can mint an authenticated session directly, so a test of a protected route does not have to drive the login form:
let client = TestApp::new().routes(routes![dashboard]).build();
client.acting_as(42).await; // alias: login_as(42)
client.get("/dashboard").send().await.assert_ok();
client.log_out();
client.get("/dashboard").send().await.assert_status(401);
acting_as sets identity only — authorization still runs, so a user who
lacks the required role or scope is still denied. It needs a session-store
handle, which means a client built via TestApp::build() on the default memory
backend (not TestApp::from_router, and not a Redis-backed one).
Test the real login handler too, at least once: assert that the session cookie changes on login (fixation), that a wrong password and an unknown address are indistinguishable, and that logout makes the old cookie unusable. See the testing guide.
Production checklist
- [ ] A signing secret is provisioned. The production profile fails fast without one, so this is really a check on every other environment you care about: without it the router installs no signing keys and session cookies carry unsigned ids.
- [ ]
session.backend = "redis"(with theredisfeature) for any multi-replica or restart-sensitive deployment, orallow_memory_in_production = trueset deliberately. - [ ]
session.secure = true,http_only = true,same_site = "Lax"(or"Strict"), and amax_age_secsyou actually want. - [ ] Login, signup, and password reset all call
rotate_id(). - [ ] Login responses are non-enumerating in body and status, with the dummy-hash verify in place so timing does not separate known from unknown addresses by a bcrypt's width.
- [ ]
[auth.password]reviewed; considerbreach_check = "fail_open". - [ ]
[auth.lockout]left enabled,AUTUMN_ADMIN_SECRETset, and the unlock route network-restricted. Theaccount_lockedlog digest is salted fromAUTUMN_SECURITY__SIGNING_SECRET— production already requires this (see signing secrets), so it needs no separate setup. - [ ] Logout revokes the remember chain and the tracking row, not just the session — a failed remember-chain delete fails the logout request, so watch for it in error monitoring.
- [ ] Every authenticated route reaches
require_tracked_session(directly or via middleware) if you rely on session revocation —#[secured]alone does not enforce it. - [ ]
/loginand/forgot-passwordcarry#[throttle]. - [ ] Sensitive routes carry
#[step_up]. - [ ]
autumn routes auditshows no unintentionally public route — see the security posture manifest. - [ ] Secrets (OAuth client secrets, admin secret) come from the environment or
the credentials store, not
autumn.toml.
Where to go next
- OAuth2 / OIDC — social and enterprise sign-in, provider presets, and account-linking policy.
- Step-up authentication —
#[step_up]for sudo-mode re-verification before destructive actions. - Authorization —
Policy,Scope, and#[authorize]for "may this user touch this record?". - Two-factor authentication (2FA / MFA) —
autumn generate auth User --totpadds a TOTP second factor on top of the password. - Passwordless sign-in —
autumn generate auth User --passkeysor--magic-linkreplaces the password instead of adding a factor to it, with its[auth.webauthn]and[auth.magic_link]settings. - Rate limiting and bot protection — the volumetric half of credential-stuffing defence.
- Submit tokens — at-most-once signup and reset forms.
- API tokens —
autumn token issue | list | rotate | revokefor the bearer tokens that carry#[secured(scopes = [...])]grants. - Signing secrets — the key behind session cookies, CSRF tokens, and flash state.
- Middleware — where the session, CSRF, and security-header layers sit in the stack.
- Audit logging — the actor
#[secured]publishes, and how writes get attributed to it. - Testing —
acting_as/login_asand the request-level assertions. - Coming from other frameworks — Devise,
authenticate_user!, Spring Security, and@login_requiredmapped onto Autumn. - Rustdoc:
autumn_web::session,autumn_web::auth,autumn_web::auth::password,autumn_web::auth::remember.
Sessions as authoritative edge identity
Edge authentication has separate verification, authoritative storage, and claim
projection stages (ADR-0005). Redis is the
first-party shared path; DragonflyDB and Valkey use the same [session.redis]
URL/prefix configuration and RedisStore, not backend-specific store types. The
adapter uses only GET, TTL-bearing SET, and DEL.
Choose consistency deliberately: logout/revocation requires the authoritative
store's current answer. An opportunistic cache is not an authority—eviction is a
miss, and stale hits could retain revoked access. Cross-region authority adds
latency; colocate it or let identity-required requests fall through to origin.
Custom stores implement SessionStore; custom identity sources implement
EdgeIdentityProvider, keeping cookie and signing rules out of plugins.