# 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](samples.md#the-samples) 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](../workflow.md#when-the-product-grows-a-need-add-the-part)). 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`](../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`.

| `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_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](#claiming-an-unverified-account-pre-hijacking)).
- `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:

```rust
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:
  ```rust
  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](security.md#the-header-matrix)).
   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](channels.md#security)), 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`)

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

<a id="password-variants-signup-invite"></a>

## 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](security.md#rate-limiting-kernelrate_limitrs)). 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 `/`.

<a id="sign-up-signup-only-registrationsrs-new-create"></a>

### 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](security.md#checklist): "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](api.md#authentication-api-tokens)). Finally start a fresh session and redirect to `/`. The same three deletes run on a password **change** (`account.rs`).

<a id="magic-link-variants-magic-link-magic-link-invite"></a>

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

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](models.md#validations));
   - 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](#claiming-an-unverified-account-pre-hijacking)).

   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](test-standards.md):

- **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](security.md#rate-limiting-kernelrate_limitrs)).
- 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](mailers.md):

> 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)

| 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](api.md#authentication-api-tokens). 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.

```rust
// 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](models.md#scoped-lookups-tenancy)).
- 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](test-standards.md#5-coverage-and-test-strength)). 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](models.md#scoped-lookups-tenancy)). 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](test-standards.md#4-tenant-isolation-suite-every-app-with-accounts). `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.
