.offrails/guides/auth.md
Authentication & Authorization
TL;DR
ACCOUNTSpicks the variant: the sign-in methods joined by+(password,magic-link,password+magic-link), theninvitewhen sign-up is closed (password invite). Invite-only is one switch (DEFAULT_SIGNUP/APP_AUTH_SIGNUP). Generated bygenerators/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:
RequireUseron 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. Themagic-linkvariant 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.
ACCOUNTS | Login | New accounts | Email needed |
|---|---|---|---|
magic-link | One-time link (or 6-digit code) by email; no passwords | Created on first login (public sign-up) | Yes (dev mailbox locally; a provider before launch) |
magic-link invite | Same | Only invited people (app task auth_invite <email>); unknown emails get nothing | Yes |
password | Email + password | Public sign-up form | For verification and reset |
password invite | Email + password | Only invited people: the invitation sets the password | For invites and resets |
password+magic-link | Either | Public 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_digestisNOT NULLin 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_linksstores the email, not a user id, so the same flow serves existing readers and first-time sign-ups.email_verified_atisNULLuntil something sent to the address is used (claiming an unverified account).roleexists only if the interview chose roles (DeriveActiveEnum:admin,member).- Multi-tenant apps add
accountsandmemberships(see below). CleanupJobdeletes 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 defaultsalloc,getrandomandpassword-hashare all you need). 0.6 generates the salt itself:(0.5'suse 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();hash_password(pw, &salt)withSaltString::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::Modeldoesn't deriveSerialize, so it can't leakpassword_digestinto JSON or props by accident.
Sessions
Database-backed, as in Rails 8:
- Start (
session::start(db, user_id, ip, user_agent) -> (Model, raw_token)): insert the digest, then set the cookiesession_token=<token>; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000(30 days). In production (secure = config.is_production()) the name is__Host-session_tokenand; Secureis added: the__Host-prefix stops a sibling subdomain from planting a session (security). Format this header by hand inkernel/session.rs(name(secure),cookie(token, secure),clear_cookie(secure)withMax-Age=0).read(headers, secure)accepts onlyname(secure), so production ignores a plainsession_tokencookie. Tests that set the cookie useksession::name(config.is_production()). axum-extra doesn't re-exportcookie::time, soCookie::max_agewould need an extra crate. - Every request:
load_session(private, incontrollers/application.rs) reads the cookie and runssession::find_by_token(db, token, now). That's one query:find_also_related(user::Entity)filtered onlast_seen_at > now − 30 days. The result,Noneincluded, is cached in the request extensions (LoadedSession), soCtx,CurrentUser,RequireUserandCurrentSessionin one handler cost a single query.session::touchbumpslast_seen_atat most once an hour. - Logout: delete the session row (
CurrentSessionextractor) and clear the cookie. Replaying the old cookie must not log anyone in. There's a test for that. - Log out everywhere (password reset or change):
session::destroy_all_for_user. - 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 withsession::destroyin the sign-in transaction, before starting the new one, never by just overwriting the cookie.destroywrites 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 getsbyerevoked, 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)
| Extractor | Behavior |
|---|---|
Ctx | Layout 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 |
ClientIp | From kernel/rate_limit.rs. None in oneshot tests |
Return-to (kernel/return_to.rs)
- Store:
error::shape_errorsseesUnauthorizedon a GET that isn't under/api. It appends_return_to=<base64url(path+query)>; Path=/; HttpOnly; SameSite=Lax; Max-Age=600to 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 aLazyLock. 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./apigets plain 429 JSON. Even the right password is refused while limited. - A logged-in reader who opens
/session/newor/registration/newis 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)
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, enqueuePasswordResetEmailJob { user_id }. Always redirect to/session/newwith the same flash: "If that address exists, we've sent password reset instructions."- The job creates the token (
password_reset::issue(db, user_id, now), 15-minute expiry), rendersmailers::passwords::resetand callsstate.mailer.deliverdirectly.MAX_ATTEMPTS = 5; if the user no longer exists, it returnsOutcome::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/jobscould 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.
- Why not
- The link is
{APP_URL}/passwords/{token}/edit.editcheckspassword_reset::find_valid(db, token, now)(unexpired, by digest). An invalid or used link redirects to/passwords/newwith "That password reset link is invalid or has expired." 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_tokenswhen 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).
Magic-link variants (magic-link, magic-link invite)
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
| Step | htmx | inertia | spa |
|---|---|---|---|
| Email form | GET /session/new (email only) | same | (SPA route) |
| Request a link | POST /login_links {email} → 303 /login_links/sent | same | POST /api/login_links → 202 |
| "Check your email" | GET /login_links/sent | same | (SPA route) |
| Confirm page | GET /login_links/{token} | same | (SPA route reading the token) |
| Log in | POST /login_links/{token} | same | POST /api/login_links/{token} |
| Log out | POST /session/delete | DELETE /session | DELETE /api/session |
-
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 (withRetry-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 }. Undermagic-linkthat's every syntactically valid email (sign-up happens on first login); undermagic-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.
- Normalize the email, then apply the rate limits: per IP + action, and per email (
-
The job issues the link: a
login_linksrow (email, digest,expires_at = now + 15 min). It rendersmailers::login_links::loginwith{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. -
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/newwith "That login link is invalid or has expired." -
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) withINSERT … ON CONFLICT (email) DO NOTHINGthen 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): takeOption<CurrentSession>in the handler, which in axum 0.8 needsimpl OptionalFromRequestParts<AppState> for CurrentSession(a plainFromRequestPartsimpl no longer gives youOption<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(nullabletimestamp_with_time_zone). Sign-up leaves itNULL; 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,truewhen 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 setpassword_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(passwordwith 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-linkboth get a login email (sign-up happens on first login); undermagic-link invite, only the known one (drain_jobs, thensent_emails). GETon the link twice, thenPOST: still logs in (scanner-safe). A secondPOST→ 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=trueand varyX-Forwarded-Forper request, so each request has its own IP key while the email key is shared (security). - First login under
magic-linkcreates exactly one user. Undermagic-link invite, an unknown email creates nothing and gets no mail. - Return-to is honored after the POST.
/admin/jobsargs forLoginLinkEmailJobcontain 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 withAPP_TRUSTED_PROXY=true, and use a rate limiter built forAppEnv::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 intoCtx.dev_mailbox(htmx), or adev_mailbox: boolshared prop (inertia) /GET /api/sessionfield (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 hashx-boost="false"because the mailbox is a kernel page with its own stylesheet, and a boosted swap would render it unstyled. inertia/spa: aDevMailboxHintcomponent 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/sentin 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_onlyin the reference app).
Routes (password variants)
| Purpose | htmx | inertia | spa (/api) |
|---|---|---|---|
| Login form | GET /session/new | GET /session/new | (SPA route) |
| Log in | POST /session | POST /session | POST /api/session |
| Log out | POST /session/delete | DELETE /session | DELETE /api/session |
| Current user | (in Ctx) | (shared prop) | GET /api/session |
| Sign up (open sign-up) | GET /registration/new, POST /registration | same | POST /api/registration |
| Forgot password | GET /passwords/new, POST /passwords | same | POST /api/passwords |
| Reset | GET /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) +apimode:POST /api/v1/userscreates the user and the first token in one transaction (no session, no sign-up page).POST /api/v1/tokensis the API's "login".… invite/magic-link+apimode: 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),
authorizegives 403. For private, owned data, scope instead of check:post::find_for(&db, &user, id)filters by owner in the query and returnsNotFound, so another user's id doesn't reveal that the record exists. The rawfindispub(in crate::models), so a handler can't compile the unscoped call (models). - Nested resources authorize against the parent first (
reviews::createloads the book and checksbooks::can_view). - Policies are pure and unit-tested with a truth table: every action × every actor (owner, other user, admin, other tenant). Run
cargo mutantsonsrc/policieswith zero survivors (test-standards). A policy that is constant by spec (can_view → true) produces an equivalent mutant; list it in.cargo/mutants.tomlwith 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 getsaccount_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, andForbiddenif 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. Rawfind/destroyare 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 inROUTESwith another tenant's ids → 404 or its specced status) andtests/app/guard_test.rs(no unscoped lookups in handlers). Templates: test-standards §4.offrails checkfails 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 aPOST. - 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::destroyit 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
Securein 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::Modelinto props or JSON and leakingpassword_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
PASSWORDonce per test binary (tokio::sync::OnceCell) and insert users with that digest.models::user's own tests cover the real hashing.