.offrails/guides/auth.md

Authentication & Authorization

TL;DR

  • ACCOUNTS picks the variant: the sign-in methods joined by + (password, magic-link, password+magic-link), then invite when sign-up is closed (password invite). Invite-only is one switch (DEFAULT_SIGNUP / APP_AUTH_SIGNUP). Generated by generators/authentication.md.
  • Tokens and sessions are random, stored as SHA-256 digests only, compared after hashing; session cookie HttpOnly + SameSite=Lax, Secure and __Host- in production.
  • Magic links: GET shows a confirm page, POST consumes (scanners can't burn it); single use, 15 min; the token is minted inside the job; identical responses for known and unknown emails; rate limits per IP and per email.
  • Authorization: RequireUser on every protected handler plus a policy or an owner-scoped query; another user's record is 404.
  • Logout, password change and reset revoke sessions, including open sockets and streams; dev screens show the mailbox hint.

Read the rest when adding accounts, changing login, writing policies, or doing multi-tenancy.

Use the part. The auth-core, auth-password, auth-magic-link and auth-totp parts are this guide built, tested on SQLite and Postgres and security-reviewed, with more on top: an account security page (devices, activity), re-auth, email change, security emails with a "this wasn't me" link, an audit trail, email verification, a 6-digit code beside the magic link, and TOTP two-factor with recovery codes. They bring their own schema: in an app that already has accounts, adopting them is a data migration. When the product needs this, add the part rather than writing it from this guide: cargo offrails add-part auth-magic-link or auth-password (the method matching ACCOUNTS; each brings auth-core, mailer and dev-mailbox), plus auth-totp for two-factor (dependencies come first; workflow). Keep its // SECURITY: markers and the tests its CHECKS.md cites. This guide stays the spec: hand-write from it only when no part fits, or the human declined the part.

The equivalent of Rails 8's rails generate authentication (and Phoenix's phx.gen.auth), plus the authorization layer Rails leaves to Pundit. Installed as parts (cargo offrails add auth-core auth-password, auth-magic-link, auth-totp; invite-only is a switch), with generators/authentication.md as the order of work and what they leave to you. This guide is the spec.

Verified on 2026-10-12 by building the reference app (htmx, ACCOUNTS: signup) against argon2 0.6, sha2 0.11, rand 0.10, axum 0.8.9 and SeaORM 2.0.4, with green tests on SQLite and Postgres 18. The magic-link variant was verified on 2026-10-13 by a fresh agent building a separate app (Linkdrop) from these docs alone: 168 tests on SQLite and Postgres 18, and the full flow in a real browser and through the mailbox JSON (scanner-safe confirm page, single use, expiry, enumeration-safe responses, per-IP and per-email limits, no token in job args).

Variants (ACCOUNTS in the app's AGENTS.md)

ACCOUNTS is the sign-in methods joined by +, then invite when sign-up is closed: ACCOUNTS: password, magic-link, password+magic-link, magic-link invite, password+totp invite. The words before 0.7.3 map onto it: signup = password, invite = password invite, magic-link-invite = magic-link invite.

ACCOUNTSLoginNew accountsEmail needed
magic-linkOne-time link (or 6-digit code) by email; no passwordsCreated on first login (public sign-up)Yes (dev mailbox locally; a provider before launch)
magic-link inviteSameOnly invited people (app task auth_invite <email>); unknown emails get nothingYes
passwordEmail + passwordPublic sign-up formFor verification and reset
password inviteEmail + passwordOnly invited people: the invitation sets the passwordFor invites and resets
password+magic-linkEitherPublic sign-up (… invite: invited only)Yes
none———

Open or invite-only is one switch for every method, not code to write: DEFAULT_SIGNUP in src/config.rs (Signup::Open or Signup::Invite; cargo offrails new … --invite sets it), overridden per deploy by APP_AUTH_SIGNUP=open|invite. Invite-only, the sign-up pages answer 404 and their links are hidden (ctx.signup_open), a magic link goes only to an existing account and never creates one, and people get in with app task auth_invite <email> (or auth_invite::invite from an admin screen). Set it in src/config.rs, not only in a deploy's env, so a forgotten variable can't open sign-up.

The variants share sessions, extractors, return-to, rate limiting and the dev mailbox hint. They differ only in how a session starts and who may start the first one.

Tables

users            id, email (lowercased, unique users_email_key), password_digest, email_verified_at?, role?, created_at, updated_at
sessions         id, user_id → users (cascade, indexed), token_digest (unique sessions_token_digest_key),
                 ip?, user_agent?, created_at, last_seen_at
password_resets  id, user_id → users (cascade, indexed), token_digest (unique), expires_at, created_at   (password variants)
login_links      id, email, token_digest (unique), expires_at, created_at                                (magic-link variants)
  • password_digest is NOT NULL in the password variants and nullable in the magic-link variants (where it's only set if the reader later chooses "also set a password").
  • login_links stores the email, not a user id, so the same flow serves existing readers and first-time sign-ups.
  • email_verified_at is NULL until something sent to the address is used (claiming an unverified account).
  • role exists only if the interview chose roles (DeriveActiveEnum: admin, member).
  • Multi-tenant apps add accounts and memberships (see below).
  • CleanupJob deletes sessions idle for more than 30 days, resets older than 1 day, and expired login links. There's no separate recurring job.

Tokens (kernel/session.rs)

One token recipe for sessions, resets and login links:

pub fn generate_token() -> String { URL_SAFE_NO_PAD.encode(rand::random::<[u8; 32]>()) }       // 43 chars
pub fn digest(token: &str) -> String { Sha256::digest(token.as_bytes()).iter().map(|b| format!("{b:02x}")).collect() }
  • Only digest(token) is stored. A database leak doesn't leak sessions or links.
  • Look rows up by digest (Column::TokenDigest.eq(digest(&raw))), never by the raw token.
  • sha2 0.11's output doesn't implement LowerHex, so hex-encode it byte by byte as shown.

Passwords (password variants)

  • argon2 0.6 (cargo add argon2; the defaults alloc, getrandom and password-hash are all you need). 0.6 generates the salt itself:
    use argon2::{Argon2, password_hash::{PasswordHasher, PasswordVerifier, phc::PasswordHash}};
    let digest = Argon2::default().hash_password(password.as_bytes())?.to_string();          // "$argon2id$v=19$…"
    let ok = Argon2::default().verify_password(password.as_bytes(), &PasswordHash::new(&digest)?).is_ok();
    (0.5's hash_password(pw, &salt) with SaltString::generate(&mut OsRng) no longer compiles.)
  • Both calls are CPU-heavy, so wrap them in tokio::task::spawn_blocking (user::hash_password, user::verify_password). A malformed digest is a mismatch, not an error.
  • Validation: 12–72 characters, counted with chars(). Nothing else (no composition rules). Messages: can't be blank, is too short (minimum is 12 characters), is too long (maximum is 72 characters).
  • user::Model doesn't derive Serialize, so it can't leak password_digest into JSON or props by accident.

Sessions

Database-backed, as in Rails 8:

  1. Start (session::start(db, user_id, ip, user_agent) -> (Model, raw_token)): insert the digest, then set the cookie session_token=<token>; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000 (30 days). In production (secure = config.is_production()) the name is __Host-session_token and ; Secure is added: the __Host- prefix stops a sibling subdomain from planting a session (security). Format this header by hand in kernel/session.rs (name(secure), cookie(token, secure), clear_cookie(secure) with Max-Age=0). read(headers, secure) accepts only name(secure), so production ignores a plain session_token cookie. Tests that set the cookie use ksession::name(config.is_production()). axum-extra doesn't re-export cookie::time, so Cookie::max_age would need an extra crate.
  2. Every request: load_session (private, in controllers/application.rs) reads the cookie and runs session::find_by_token(db, token, now). That's one query: find_also_related(user::Entity) filtered on last_seen_at > now − 30 days. The result, None included, is cached in the request extensions (LoadedSession), so Ctx, CurrentUser, RequireUser and CurrentSession in one handler cost a single query. session::touch bumps last_seen_at at most once an hour.
  3. Logout: delete the session row (CurrentSession extractor) and clear the cookie. Replaying the old cookie must not log anyone in. There's a test for that.
  4. Log out everywhere (password reset or change): session::destroy_all_for_user.
  5. Signing in over a session (any method: password, magic link, a reset; a shared computer where Ann signs in while Bob's session is still in the browser): the handler takes Option<CurrentSession> and ends that session with session::destroy in the sign-in transaction, before starting the new one, never by just overwriting the cookie. destroy writes the session-ended signal, so the previous person's open sockets and streams close at once (channels), not at their next revalidation, and the old token is dead even if it was copied. Test: sign in as A, open a stream (or socket) as A, sign in as B in the same browser → A's connection gets bye revoked, and replaying A's old cookie is logged out.

Build responses that set cookies with AppendHeaders([(SET_COOKIE, …), (SET_COOKIE, …)]) before the Flash and the Redirect. A plain header array would replace the earlier Set-Cookie instead of appending.

Extractors (in controllers/application.rs)

ExtractorBehavior
CtxLayout context, including current_user: Option<User> and dev_mailbox: bool (below). Rejection: AppError (DB errors only)
CurrentUser(Option<User>)Never rejects (DB errors aside)
RequireUser(User)AppError::Unauthorized, which error.rs maps per flavor: htmx → HX-Redirect: /session/new; HTML/inertia → 303 to /session/new; /api → 401 JSON
CurrentSession(session::Model)For logout. Unauthorized if logged out
UserAgent(Option<String>)For the sessions table
RequireAdmin(User)Only if roles exist: Unauthorized if logged out, Forbidden if not admin. AdminAccess uses it
ClientIpFrom kernel/rate_limit.rs. None in oneshot tests

Return-to (kernel/return_to.rs)

  • Store: error::shape_errors sees Unauthorized on a GET that isn't under /api. It appends _return_to=<base64url(path+query)>; Path=/; HttpOnly; SameSite=Lax; Max-Age=600 to the redirect. The value is base64url because paths can contain ; and ,.
  • Use: after a successful login, return_to::read(&headers) gives the path. Redirect there (or to /), and clear the cookie in the same response, so it's used once.
  • Validate: only same-origin relative paths are allowed. A path must start with /, and must not start with // or /\ (browsers treat \ as /) or contain control characters. Anything else is ignored.

Password variants (password, password invite)

Login (sessions.rs: new, create, destroy)

  • Normalize the email with email.trim().to_lowercase() (the same function used at sign-up).
  • No enumeration: user::authenticate(db, email, password) always runs exactly one argon2 verify. If there's no such user, it verifies against a dummy digest computed once in a LazyLock. A failure re-renders the form with 422 and the alert "Try another email address or password.", keeping the email.
  • Rate limit: 10 attempts per 3 minutes per IP + action (ip.key("sessions#create")), the window fixed from the first attempt; refused attempts don't count and never extend the lockout, so a locked-out person gets in 3 minutes after their first attempt however often they retry (security). Development allows 1000 per window. Over the limit, re-render the form with 429 and the alert "Try again later.", so humans see why and tests can assert the status. /api gets plain 429 JSON. Even the right password is refused while limited.
  • A logged-in reader who opens /session/new or /registration/new is redirected to /.

Sign-up (open sign-up only; registrations.rs: new, create)

Validate SignupParams { email, password }. Then, in one transaction, create the user and the session, so there's never an account without a login or the reverse. A duplicate email is caught from the unique index (no pre-query). When the app sends email, don't say "Email has already been taken" (that enumerates accounts): answer exactly as for a new sign-up ("Check your email to confirm") and send the existing owner a "someone tried to sign up with your address" note instead. Without email, the 422 is unavoidable; say so in docs/decisions.md. On a failed submit, re-render the form without the password. Enqueue a welcome email only if EMAIL is configured. Invite-only apps (password invite) answer 404 here (the handlers take SignupOpen); people come in through app task auth_invite <email>, whose single-use link sets the password and verifies the address.

Password reset (passwords.rs: new, create, edit, update)

  1. POST /passwords {email}: rate limited per IP + action like login, and per address (format!("passwords#create:{email}"), 10 per 3 minutes, plus the daily cap below), because each request emails someone who didn't ask. If the user exists, enqueue PasswordResetEmailJob { user_id }. Always redirect to /session/new with the same flash: "If that address exists, we've sent password reset instructions."
  2. The job creates the token (password_reset::issue(db, user_id, now), 15-minute expiry), renders mailers::passwords::reset and calls state.mailer.deliver directly. MAX_ATTEMPTS = 5; if the user no longer exists, it returns Outcome::Cancel.
    • Why not deliver_later_until(&txn, email, expires_at)? That would put the rendered email, reset link included, into job args, where anyone who can open /admin/jobs could read it and take over the account (security: "job args carry no secrets"). Issuing the token at send time also means the 15 minutes start when the email actually leaves.
  3. The link is {APP_URL}/passwords/{token}/edit. edit checks password_reset::find_valid(db, token, now) (unexpired, by digest). An invalid or used link redirects to /passwords/new with "That password reset link is invalid or has expired."
  4. POST /passwords/{token} (htmx) / PATCH (inertia): password_reset::consume. Validate the new password first (422 with the form; the link keeps working). Then, in one transaction: set the digest, delete all of that user's resets (single use), sessions (log out everywhere) and API tokens (api_tokens when the app has an API: a bearer token minted by whoever took the account over must die with the password, api). Finally start a fresh session and redirect to /. The same three deletes run on a password change (account.rs).

Phoenix 1.8's default. There are no passwords to leak, reuse or reset. Every rule from the password variants about tokens, sessions, rate limits and enumeration still applies.

Flow

Stephtmxinertiaspa
Email formGET /session/new (email only)same(SPA route)
Request a linkPOST /login_links {email} → 303 /login_links/sentsamePOST /api/login_links → 202
"Check your email"GET /login_links/sentsame(SPA route)
Confirm pageGET /login_links/{token}same(SPA route reading the token)
Log inPOST /login_links/{token}samePOST /api/login_links/{token}
Log outPOST /session/deleteDELETE /sessionDELETE /api/session
  1. Request (login_links::create):

    • Normalize the email, then apply the rate limits: per IP + action, and per email (format!("login_links#create:{email}")), each 10 per 3 minutes; plus a daily cap per address, 20 emails a day (format!("emails/day:{email}"), Duration::from_secs(86_400)), shared with password reset requests. The per-IP limit doesn't stop a flood from many IPs, and 10 per 3 minutes alone is still 4,800 emails a day to one inbox. Over any of these, send nothing and re-render the form with 429 and "Try again later." like login (with Retry-After). That reveals nothing about the address: any address, registered or not, hits the same limits.
    • If the reader may log in, enqueue LoginLinkEmailJob { email }. Under magic-link that's every syntactically valid email (sign-up happens on first login); under magic-link invite, only an existing user.
    • Always redirect to /login_links/sent, with no hint about whether the address exists. Invalid email syntax re-renders the form with 422 "Email is invalid". That's a format error, not enumeration.
  2. The job issues the link: a login_links row (email, digest, expires_at = now + 15 min). It renders mailers::login_links::login with {APP_URL}/login_links/{token} and delivers it directly. MAX_ATTEMPTS = 5. The token never sits in job args; the email does, and that's personal data, not a secret.

  3. Confirm page (GET /login_links/{token}): if the token is valid and unexpired, render "Log in as ann@example.com" with one button that POSTs. GET never consumes the token. Email security scanners and link previews prefetch URLs, and a GET that logged in would burn the link before the human clicks it. An invalid link redirects to /session/new with "That login link is invalid or has expired."

  4. Consume (POST /login_links/{token}): login_link::consume(db, raw, now) -> Option<(User, Session, raw_session_token)>, one transaction that:

    • deletes the row (single use; a second POST finds nothing);

    • finds the user by email, or creates it under magic-link (no password digest) with INSERT … ON CONFLICT (email) DO NOTHING then a select (two first logins racing can't fail, and inside a Postgres transaction a unique violation would abort the transaction, see models);

    • starts the session (inserts the session row, returns the raw cookie token).

    • in an app that also has passwords, claims an unverified account first (below).

    Then set the cookie, honor return-to, and redirect. If the reader was logged in as someone else, end that session in the same transaction with session::destroy (Sessions, item 5): take Option<CurrentSession> in the handler, which in axum 0.8 needs impl OptionalFromRequestParts<AppState> for CurrentSession (a plain FromRequestParts impl no longer gives you Option<T>).

Claiming an unverified account (pre-hijacking)

An account isn't proven to belong to its address until something sent to that address is used. With open password sign-up (password), anyone can register victim@example.com with a password they know, before the victim does, and set things up on it (an API token, a webhook, a notification address), hoping the owner later gets in another way (a reset link, or a magic link in an app that offers both) and starts using an account the attacker still holds. The first proof of the address claims the account and drops everything set up before it:

  • users.email_verified_at (nullable timestamp_with_time_zone). Sign-up leaves it NULL; a magic-link first login creates the user verified (the link was the proof); an invite sets it when the invite is accepted.
  • user::claim_unverified(txn, user_id, now) -> Result<bool, DbErr>: UPDATE users SET email_verified_at = now WHERE id = ? AND email_verified_at IS NULL, true when it updated the row. The UPDATE locks the row, so a racing request either committed before (and its work is dropped below) or waits and sees the account verified.
  • Call it in the transaction of every proof: consuming a password reset, consuming a login link (an app with both methods), accepting an invite. When it returns true, in that transaction: delete every session (destroy_all_for_user, which signals open connections), every API token, every pending reset and login link of that user, and anything else that grants access or redirects mail (webhooks, forwarding addresses) the app has; on a link sign-in also set password_digest = NULL, because whoever registered chose that password. Then continue (set the new password, start the session).
  • Tests: an_api_token_made_before_the_first_reset_is_revoked (password with an API), a_password_set_before_verification_dies_when_the_owner_signs_in_by_link (an app with both methods), and that a second reset on a verified account keeps the user's API tokens.

Variants with nothing to claim are n/a: magic-link and magic-link invite without passwords (the first login is the proof), and password invite where the invitation link is the only way in.

Token URLs leak through history, caches and referrers. The confirm page (GET /login_links/{token}, and GET /passwords/{token} in the password variants) responds with Cache-Control: no-store and Referrer-Policy: no-referrer, so the token isn't cached, and isn't sent to any third party the page links to. 5. Optional password later: a "Set a password" page under the account settings lets a reader add a password. Password login then works the same as in password, with authenticate treating a NULL digest as a mismatch after the dummy verify. Only add this if a spec asks for it.

Required tests

On top of test-standards:

  • First login can't abort its transaction (Postgres): call insert_if_absent(&txn, email) twice in one transaction, then keep using the transaction (e.g. create the session). The race itself isn't testable; this is, and it fails on Postgres with the catch-the-violation version.
  • A known email and an unknown one get byte-identical responses (status, location, flash). Mail: under magic-link both get a login email (sign-up happens on first login); under magic-link invite, only the known one (drain_jobs, then sent_emails).
  • GET on the link twice, then POST: still logs in (scanner-safe). A second POST → invalid.
  • Expired (issue with now − 16 min) → invalid. 14 minutes → valid.
  • Refused attempts don't extend the window: exhaust the limit with a 1 s window (state.rate_limit.check(&key, 10, Duration::from_secs(1)) directly, kernel test), keep retrying, and the first check after the window passes succeeds (wait_until, no fixed sleep).
  • Rate limit by IP, and by email across different IPs: build the test app with APP_TRUSTED_PROXY=true and vary X-Forwarded-For per request, so each request has its own IP key while the email key is shared (security).
  • First login under magic-link creates exactly one user. Under magic-link invite, an unknown email creates nothing and gets no mail.
  • Return-to is honored after the POST.
  • /admin/jobs args for LoginLinkEmailJob contain no /login_links/ URL.
  • The daily cap: the 21st request for one address in a day sends nothing and gets the 429 form, for a registered and an unregistered address alike (link_requests_are_capped_per_address_per_day; build the requests from different IPs with APP_TRUSTED_PROXY=true, and use a rate limiter built for AppEnv::Test, since development allows 1000 per window).
  • Signing in by link over another reader's session ends it (sessions, item 5).
  • The dev mailbox hint is on /login_links/sent (development) and absent in production config.

Dev mailbox hint (all variants)

Phoenix's "You are running the local mail adapter" notice. In development, every auth screen that sends or talks about an email shows a notice linking to the dev mailbox:

You're running locally, so emails aren't sent — they land in your local mailbox. Open the mailbox →

  • Condition: config.is_development() && state.mailer.is_local(), computed once into Ctx.dev_mailbox (htmx), or a dev_mailbox: bool shared prop (inertia) / GET /api/session field (spa). Never true in production or in tests (memory mailer).
  • htmx: templates/shared/_dev_mailbox_hint.html, guarded by {% if ctx.dev_mailbox %}. The link has hx-boost="false" because the mailbox is a kernel page with its own stylesheet, and a boosted swap would render it unstyled. inertia/spa: a DevMailboxHint component fed by that flag.
  • Where:
    • log in;
    • sign up;
    • forgot password;
    • reset password;
    • the page the reset request lands on (the login page, so it's covered);
    • mandatory on /login_links/sent in the magic-link variants.
  • Test: present with development config, absent with production config and test config (accounts_test::auth_screens_link_to_the_dev_mailbox_in_development_only in the reference app).

Routes (password variants)

Purposehtmxinertiaspa (/api)
Login formGET /session/newGET /session/new(SPA route)
Log inPOST /sessionPOST /sessionPOST /api/session
Log outPOST /session/deleteDELETE /sessionDELETE /api/session
Current user(in Ctx)(shared prop)GET /api/session
Sign up (open sign-up)GET /registration/new, POST /registrationsamePOST /api/registration
Forgot passwordGET /passwords/new, POST /passwordssamePOST /api/passwords
ResetGET /passwords/{token}/edit, POST /passwords/{token}GET …/edit, PATCH /passwords/{token}PATCH /api/passwords/{token}

In ROUTES, every auth route is Access::Public except log-out (Access::User).

API tokens (API set, or FRONTEND_MODE: api)

Programs authenticate with bearer tokens, not cookies. The table, routes, extractors (ApiUser, OptionalApiUser), scopes and rate limits are specified in api. How they combine with ACCOUNTS:

  • password (open sign-up) + api mode: POST /api/v1/users creates the user and the first token in one transaction (no session, no sign-up page). POST /api/v1/tokens is the API's "login".
  • … invite / magic-link + api mode: users get their first token from an operator (app task tokens create <email>), or from a magic-link flow whose confirm step mints a token instead of a session. Spec it before building.
  • UI modes with API: keep the cookie session for the browser, and add a "API tokens" settings page (list, create with name and scopes, revoke) that shows a new token once.
  • Tokens use the same kernel::session::{generate_token, digest} as sessions: random 32 bytes, only the SHA-256 is stored, never logged, never put in job args.

Authorization

Authorization is plain functions in src/policies/<plural>.rs. No DSL, no crate.

// src/policies/mod.rs
pub fn authorize(allowed: bool) -> Result<(), AppError> { if allowed { Ok(()) } else { Err(AppError::Forbidden) } }

// src/policies/books.rs
pub fn can_view(_user: &User, _book: &Book) -> bool { true }       // spec: every logged-in reader
pub fn can_edit(user: &User, book: &Book) -> bool { book.owner_id == user.id }
pub fn can_destroy(user: &User, book: &Book) -> bool { can_edit(user, book) }
  • Call policies::authorize(policies::books::can_edit(&user, &book))? in every handler that reads private data or writes. Views and props get booleans (can_edit: bool) to show or hide buttons. Hiding a button is not authorization.
  • Visible but not yours → 403. Not visible → 404. When the actor may see a record but not change it (another reader's book), authorize gives 403. For private, owned data, scope instead of check: post::find_for(&db, &user, id) filters by owner in the query and returns NotFound, so another user's id doesn't reveal that the record exists. The raw find is pub(in crate::models), so a handler can't compile the unscoped call (models).
  • Nested resources authorize against the parent first (reviews::create loads the book and checks books::can_view).
  • Policies are pure and unit-tested with a truth table: every action × every actor (owner, other user, admin, other tenant). Run cargo mutants on src/policies with zero survivors (test-standards). A policy that is constant by spec (can_view → true) produces an equivalent mutant; list it in .cargo/mutants.toml with the reason.

Multi-tenancy (only if the interview chose teams/workspaces)

  • Tables: accounts (id, name, …), memberships (account_id, user_id, role, unique(account_id, user_id)). Every tenant-owned table gets account_id NOT NULL + an index.
  • URL: /a/{account_id}/… (or subdomain, only if the spec says so).
  • CurrentAccount(Account, Membership) extractor: resolves from the path, and Forbidden if the user has no membership.
  • Every query on tenant data goes through an actor-scoped model fn (find_for(db, &user, id), all_for, destroy_for) that filters by the user's memberships. Raw find/destroy are model-private (models). No exceptions, not even "admin" screens: an admin path is its own scoped fn (all_for_admin).
  • Generated with accounts, for every TENANCY: tests/app/tenancy_test.rs (every id route in ROUTES with another tenant's ids → 404 or its specced status) and tests/app/guard_test.rs (no unscoped lookups in handlers). Templates: test-standards §4. offrails check fails without them.

Gotchas agents get wrong

  • Comparing raw tokens to the DB. Hash first, always.
  • Putting a token (or an email that contains one) in job args. Issue the token inside the job.
  • Different error messages, or different timing, for "no such email" and "wrong password". One argon2 verify on both paths.
  • A magic link that logs in on GET. Scanners burn it; confirm with a POST.
  • Signing in over an existing session by overwriting the cookie. The old session row lives on (a copied token still works) and its sockets stay open. session::destroy it in the sign-in transaction.
  • Rate limiting emailed links per IP only. A flood from many IPs fills one inbox; limit per address too, with a daily cap.
  • Treating an open-sign-up (password) account as its address owner's before anything sent there was used. Claim it on the first proof and drop what was set up before (pre-hijacking).
  • Forgetting Secure in production, or setting it in development (where it breaks login over http).
  • Open redirects via return-to, including /\evil.example.
  • Running argon2 on the async executor thread.
  • Serializing user::Model into props or JSON and leaking password_digest.
  • A rate-limited form that returns a bare 429 page. Re-render the form with the alert.
  • Factories hashing a password per user. At production cost, argon2 dominates suite time. Hash PASSWORD once per test binary (tokio::sync::OnceCell) and insert users with that digest. models::user's own tests cover the real hashing.