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

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.

CapabilityPrimitiveConfig block
Cookie-backed sessionsautumn_web::session::{Session, SessionStore}[session]
Password hashingautumn_web::auth::{hash_password, verify_password}—
Password policyautumn_web::auth::validate_password[auth.password]
Route guard#[secured], RequireAuth, Auth<T>[auth].session_key
Account lockoutgenerated login handler[auth.lockout]
Remember meautumn_web::auth::remember[auth.remember]
Session tracking / revocationgenerated *_sessions table[auth.sessions]
Re-auth for sensitive actions#[step_up][auth.step_up]
Social loginautumn generate auth --oauth …[auth.oauth2.*]

Quick start: autumn generate auth

Shell
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:

MethodPathAuth
GET/POST/signupPublic
GET/POST/loginPublic
POST/logoutAny
GET/accountRequired + email-confirmed
POST/account/destroyRequired + step-up
GET/POST/reauthAny
GET/POST/forgot-passwordPublic
GET/POST/reset-passwordPublic
GET/auth/confirm/{token}Public
GET/POST/auth/confirm/resendPublic
GET/account/sessionsRequired
POST/auth/admin/unlockX-Admin-Secret header

Optional factors compose on top, each off by default:

FlagAdds
--oauth github,googleRedirect + callback handlers, oauth_identities table, the oauth2 feature — see OAuth2 / OIDC
--totpTwo-factor authentication (2FA) with a TOTP authenticator app: enrollment + login-verify, encrypted-at-rest secrets, single-use recovery codes
--passkeysWebAuthn 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):

Rust
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

MethodWhat 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.

TOML
[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 the prod profile on it logs a warning; set session.allow_memory_in_production = true to acknowledge it deliberately (single-replica, restart-tolerant deployments).
  • redis — survives restarts and is shared across replicas. Requires the redis Cargo 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 with AppBuilder::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".

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

Rust
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:

Rust
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.

TOML
[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.

Rust
#[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_attempts update, 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 /login rather 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:

Rust
#[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).


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.

FlagMechanismRoutes it generates
--magic-linkA single-use sign-in link emailed to the addressGET/POST /login/magic, GET/POST /login/magic/verify
--passkeysWebAuthn — 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.

TOML
[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 (default 15) — 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 at 15 or less; raise it only against that tradeoff. A negative value fails deserialization rather than minting already-expired links.
  • email_cooldown_secs (default 60) — POST /login/magic skips 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:

TOML
[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:

TOML
[auth.webauthn]
rp_id     = "localhost"
rp_name   = "My App (dev)"
rp_origin = "http://localhost:3000"
  • rp_id must be the origin's own domain, or a registrable domain suffix of it. For an origin of https://app.example.com, both app.example.com and example.com are valid, and the choice sets the credential's scope: app.example.com binds it to that host, example.com also covers sibling subdomains. Pick the narrowest one that covers every origin you sign in from. A public suffix is not a registrable domain, so com is never a valid rp_id.
  • rp_origin must parse as a URL and must be an origin that rp_id covers. WebauthnBuilder rejects a mismatch, so a wrong pair surfaces as a 500 from the passkey routes rather than as a ceremony that fails in the browser.
  • Changing rp_id invalidates 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]

Rust
#[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.

Shell
# `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:

Rust
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:

Rust
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:

  1. Each failed login increments failed_attempts on the account row.

  2. At threshold, locked_at is stamped. A tracing::warn! fires with event = "account_locked", a salted SHA-256 account digest truncated to 8 bytes, and an IP prefix (IPv4 /24, IPv6 /64). Only the request that sets locked_at logs, 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.

  3. 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.

  4. A successful login clears the counter and the lock; the account auto-unlocks on the first successful login after the cool-off elapses.

TOML
[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:

  • series is stable for one device's login chain — it is the database lookup key.
  • token rotates 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:

Rust
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.

TOML
[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:

Rust
// 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).

TOML
[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:

Rust
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:

Rust
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:

QuestionAnswer
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 idrotated on begin and end
Auditone 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.

Rust
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:

Rust
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_impersonation returns 500 — leaving the session untouched — if the audit write fails or if the app has no audit sink installed at all (audit::write_from_state is a silent no-op without one, so a missing sink would otherwise mean a swap with no record). Register one with AppBuilder::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_at is 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_key must 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 claim last_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 with impersonation::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:

Rust
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.

Rust
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:

Rust
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 the redis feature) for any multi-replica or restart-sensitive deployment, or allow_memory_in_production = true set deliberately.
  • [ ] session.secure = true, http_only = true, same_site = "Lax" (or "Strict"), and a max_age_secs you 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; consider breach_check = "fail_open".
  • [ ] [auth.lockout] left enabled, AUTUMN_ADMIN_SECRET set, and the unlock route network-restricted. The account_locked log digest is salted from AUTUMN_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.
  • [ ] /login and /forgot-password carry #[throttle].
  • [ ] Sensitive routes carry #[step_up].
  • [ ] autumn routes audit shows 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 --totp adds a TOTP second factor on top of the password.
  • Passwordless sign-in — autumn generate auth User --passkeys or --magic-link replaces 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 | revoke for 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_as and the request-level assertions.
  • Coming from other frameworks — Devise, authenticate_user!, Spring Security, and @login_required mapped 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.