Prima Get an API key

Module kit / Identity and auth

Registration with email verification

identity-auth/registration-with-email-verification

Kind backend
Domain identity-auth
Maturity prod
Reusable extract
Stack fastapi, sqlalchemy-sync, postgres, bcrypt, smtp
Depends on _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/workspace-scoping, _shared/error-envelope, _shared/alembic-migrations, _shared/test-layout
Frontends identity-auth/ui/auth-screens
Reference implementation
  • alchemy_labs/backend/api/routers/auth_router.py
  • alchemy_labs/backend/services/auth_service.py
  • alchemy_labs/backend/models/user_model.py
  • alchemy_labs/backend/schemas/auth_schemas.py
  • alchemy_labs/backend/services/surface_service.py
  • alchemy_labs/backend/services/email_service.py
  • alchemy_labs/backend/tests/test_register_verification.py
  • alchemy_labs/backend/tests/test_login_verification.py
Measured on alchemy_labs d1a-token-source fd8558f 2026-09-12
Version 1

Purpose

A person gives an email address and a password and gets an account that can do nothing until the mailbox has proved itself: POST /api/auth/register answers 201 {status: "verification_required"} with no token in the body and no cookie on the response, and /login and /refresh refuse the account the same way until POST /api/auth/verify-email has consumed a mailed token. The invariant that makes it trustworthy is that the refusal is decided by two settings that default to on (REQUIRE_EMAIL_VERIFICATION, ENABLE_EMAIL_VERIFICATION) and is enforced at every place a session can be minted for a password account, so a registration cannot become a live aud=labs session by any route except the mailbox.

This recipe covers the account row and what is written beside it at creation (the surface grant rows, the personal workspace, the welcome allowance as one obligation against the prepaid ledger), the verification token's life, the four routes, and the two mail sends that hang off them. Login itself is B2; the grant table is B4; the magic link that also gates on is_verified is B5; the ledger the welcome allowance lands in is the billing-credits domain's (D2, decided 2026-09-11: prepaid credit in Prima's ledger is the one truth for AI spend, and the Metal tank, the CHF wallet and the per-user token quota the reference still writes at registration are legacy).

When to use

Include it when a service needs a self-serve account with a password, or when someone asks "why does register return 201 but no token", "the user says they never got the mail", "can I turn auto-login back on", "the account exists but cannot sign in", "where does the personal workspace come from", or "why does a new account have a 33g tank" (the answer is D2, below). Trigger phrases: "verification_required", "verify-email", "resend-verification", "set-password", "username taken".

Do not use it for the API-only signup that mints a gateway key and creates the user row in one transaction (B14 api-only-self-serve-signup, which never writes a labs grant and never sends a verification mail), for the passwordless magic-link account paths (B5, B20), for the password reset that also ends in a password_hash write (B6, which refuses passwordless accounts and accounts without a labs grant), or for admin-created accounts (B16). What a new account gets for free, and the ledger it is booked in, is the billing-credits domain (B56 billing-credits/prepaid-credit-ledger, D2); this recipe only says that the obligation is written beside the user row. The Labs SPA at fd8558f has no registration page at all (Gotchas), so a service that copies this recipe is the first thing that renders it end to end.

Stack assumptions

Measured at fd8558f (2026-09-12): fastapi==0.118.2, sqlalchemy==2.0.43 (sync Session on a bounded QueuePool, pool_size 2, max_overflow 3, pool_recycle 300, core/database.py 49 to 57; NullPool is kept only on the async engine, comment 48; get_db_context() does not auto-commit, lines 444 to 461), bcrypt==5.0.0 (hash_password truncates to 72 bytes and calls bcrypt.gensalt() with its default cost, services/auth_service.py 346 to 352), Pydantic v2 with the v1-style @validator still in schemas/auth_schemas.py. Foundations assumed: S4 (the four routes are on auth_router, mounted ALL_SURFACES at /api/auth, app_fastapi.py line 820, so the mount-time gate passes anonymous callers and the endpoints decide), S5 (create_session and the labs grant question), S6 (the personal workspace is a workspaces row owned by the new user), S7 (every refusal is HTTPException(detail=<string>) rendered by the envelope handler), S8 (email_verifications is in the baseline migration), S9.

Environment names and fail behaviour (core/config.py):

NameDefaultBehaviour
ENABLE_EMAIL_VERIFICATIONTrue (line 845)off: /verify-email and /resend-verification answer 404, create_user mints no token, register never queues the verification mail
REQUIRE_EMAIL_VERIFICATIONTrue (852)off: register auto-logs in (tokens and cookies), login and refresh stop asking is_verified; effective only while the first is on (comment 846 to 851)
EMAIL_VERIFICATION_EXPIRE_HOURS168 (440)token lifetime; compare PASSWORD_RESET_EXPIRE_HOURS 6 (441)
EMAIL_TRANSPORTsmtp (458), one of smtp, resendADR-0019: the transport is named, never inferred; smtp declared without EMAIL_SMTP_HOST, EMAIL_SMTP_USERNAME, EMAIL_SMTP_PASSWORD raises at boot (521 to 552); undeclared and empty falls to resend with a warning (556 to 565) outside production; ENVIRONMENT=production raises even when the value is only defaulted (543 to 552)
RESEND_API_KEYunsetread only when the transport is resend; transport_is_configured() (services/email_service.py 64 to 77) is the send gate, never the key
FRONTEND_URLhttp://localhost:3000 (43)the base of the mailed link /verify-email?token=<t> (email_service.py 821 to 822)
BCRYPT_ROUNDS12 (131)declared and read by nothing outside core/config.py (grep over backend/ 2026-09-12); the cost is gensalt()'s default, also 12
MAX_SESSIONS_PER_USER5 (890)only on the legacy auto-login branch, through create_session (B2)

A service built from this recipe keeps both verification flags on in every environment and sets the transport explicitly; the tests' conftest.py sets REQUIRE_EMAIL_VERIFICATION=false (line 41) and the verification suites flip it back per test with monkeypatch.

Data model

Tables this recipe writes, columns as declared in the models and the baseline migration (migrations/versions/baseline_prod_schema_001.py; the naming convention ck_<table>_<name> is core/database.py's):

-- models/user_model.py lines 57 to 155; only the columns this recipe touches
users (
  id uuid PRIMARY KEY,
  email varchar(255) UNIQUE NOT NULL,              -- lower-cased before insert (auth_service.py 619)
  username varchar(100) UNIQUE NOT NULL,           -- derived or given; lower-cased; numeric suffix on collision
  password_hash varchar(255) NULL,                 -- bcrypt; NULL is a passwordless account
  first_name varchar(100), last_name varchar(100), company varchar(200),
  role varchar(50) DEFAULT 'user',
  is_active boolean DEFAULT true,
  is_verified boolean DEFAULT false,
  email_verified_at timestamp NULL,
  -- legacy ledgers under D2 (2026-09-11): the reference writes them at registration, a service built
  -- to this recipe reads them for migration only and never writes them; the welcome allowance is a
  -- row in the prepaid ledger owned by billing-credits (B56), not a column here
  token_quota integer DEFAULT 100000, tokens_used_current_period integer DEFAULT 0, quota_reset_date timestamp,
  metal_welcome_ml integer DEFAULT 0, metal_welcome_credited boolean DEFAULT false,
  created_at timestamp, updated_at timestamp, last_login timestamp,
  CHECK (role IN ('admin','api_only','delivery','designer','medical_reviewer','user'))   -- ck_users_role, USER_ROLES lines 35 to 42
);
CREATE INDEX idx_user_email_active ON users(email, is_active);

-- models/user_model.py 186 to 197; baseline migration 1031 to 1040 creates the table without the FK
email_verifications (
  id uuid PRIMARY KEY,
  user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,   -- model-declared; migration deferred_fk_phantom_tables_001 355 to 358
  token varchar(255) UNIQUE NOT NULL,              -- secrets.token_urlsafe(32), stored raw (Gotchas)
  verified_at timestamp NULL,                      -- NULL means unused
  expires_at timestamp NOT NULL,                   -- now() + EMAIL_VERIFICATION_EXPIRE_HOURS
  created_at timestamp
);
CREATE INDEX ix_email_verifications_token ON email_verifications(token);

-- written beside the user row, owned by B4: one row per surface in LABS_SIGNUP_SURFACES
user_surface_grants (user_id, surface, granted_at, granted_by)   -- (user_id, surface) PRIMARY KEY, ON CONFLICT DO NOTHING

-- written by the bootstrap, owned by B15: workspaces, workspace_users (role 'owner'), workspace_settings

What is not stored by a service built to this recipe: a balance of any kind on users (D2: the welcome allowance is an obligation row in the prepaid ledger, B56, the way B14 writes sparks_grants beside the account row); the password (bcrypt only, 72-byte truncation on both hash and verify, auth_service.py 346 to 363); a hash of the verification token (the raw token is the row, Gotchas); the welcome mail or its status (a BackgroundTask, nothing persisted); a registration IP (logged at WARNING, auth_router.py 376 to 379, not stored).

The body shapes, from schemas/auth_schemas.py: UserRegister (11 to 29) is email: EmailStr, password: str (min_length=8, no character-class rule), optional username (3 to 50 characters, ^[a-zA-Z0-9_-]+$, lower-cased by the validator), first_name, last_name, company. EmailVerificationRequest (117) is email; EmailVerificationConfirm (120) is token; PasswordSetup (123 to 136) is token and password with min_length=8 plus one uppercase, one lowercase, one digit.

API surface

All four routes live on auth_router (mounted ALL_SURFACES, prefix /api/auth, app_fastapi.py 820) and declare Depends(auth_rate_limit): 30 requests per minute per client IP per path, 429 with Retry-After (api/dependencies/rate_limit.py 26 to 27, 38 to 60). None reads a credential; none writes request.state.

RouteAuth depTenancyCodesError detail strings
POST /api/auth/registernonecreates the tenant (a personal workspace owned by the new user)201 {status: "verification_required", id, email, username, message, user} (default); 201 with access_token, refresh_token, token_type, expires_in, user and the two cookies only when REQUIRE_EMAIL_VERIFICATION is off; 400; 422; 429; 500400 "User with this email already exists", 400 "Unable to generate unique username" (the ValueError text, auth_router.py 424 to 428); 500 "Registration failed" for any other exception (429 to 434), which at fd8558f includes a re-registration whose local part differs only in case (Gotchas). The 400 "Password is required" at 266 to 270 is dead: UserRegister.password is required with min_length=8 (auth_schemas.py 16) so the schema answers 422 first, and were the check reached its HTTPException would be caught by the except Exception at 429 and rewritten to 500
POST /api/auth/verify-emailnonenone204; 400; 404; 429400 "Invalid or expired verification token"; 404 "Email verification is not enabled"
POST /api/auth/resend-verificationnonenone202 always for an enabled feature; 404; 429body {"message": "If the email exists and is unverified, a verification link has been sent"} whether or not a mail went out (921 to 953)
POST /api/auth/set-passwordnonenone204; 400; 422; 429400 "Invalid or expired verification token"; 422 from PasswordSetup

The user object in the 201 body (auth_router.py 353 to 362): id, email, name (first and last joined, else the username), username, emailVerified, is_admin, surfaces (sorted, read live from the grant table). The same shape is returned by /login on the verification_required branch (88 to 114) so a client has one thing to parse.

Neighbouring routes that read what this recipe writes: POST /api/auth/login (B2) refuses an unverified password account with the 200 verification_required body and queues a fresh verification mail (_maybe_resend_verification, 117 to 137, 500 to 502); POST /api/auth/refresh (B2) answers 401 "Invalid refresh token: email not verified" while both flags are on (auth_service.py 801 to 806); POST /api/auth/verify-magic-link (B5) refuses the same way (1175 to 1185).

Application logic

register() (auth_router.py 234 to 434), in the order the side effects land:

  1. Username. If the body has none, the local part of the email with . and + replaced by _ (255 to 259). A given username has already been validated and lower-cased by the schema; a derived one is not run through the regex.
  2. auth_service.create_user(email, username, password, first_name, last_name, company) (auth_service.py 579 to 660), inside its own get_db_context(): an existing email is ValueError("User with this email already exists") (591 to 596; the comparison is on the email as received, the insert is lower-cased, Gotchas); a taken username gets 1, 2, ... appended up to 100, then ValueError (598 to 614); the User row is built with email.lower() and the bcrypt hash and committed (616 to 633). Then the grant rows: LABS_SIGNUP_SURFACES (labs, prima_web, prima_chat, prima_dev) unless role == "api_only", which gets API_ONLY_SURFACES (core/surfaces.py 185 to 189; auth_service.py 639 to 648, a second commit). Then the welcome allowance: a service built to this recipe writes one obligation row against the prepaid ledger owned by the billing-credits domain (B56 billing-credits/prepaid-credit-ledger, unwritten in this kit at 2026-09-12), before the first commit, the way B14 calls sparks_grants.enqueue_welcome(db, tenant_id=<workspace>) inside the account transaction (services/primaapi/identity.py 321 to 329; enqueue_welcome at services/commerce/sparks_grants.py 253; WELCOME_SPARKS at core/sparks_config.py 103). The reference at fd8558f instead writes two legacy ledgers (a dated deviation, D2): token_quota = 100000, tokens_used_current_period = 0, quota_reset_date = now + 30 days on the user row itself (628 to 631), and after the grants MetalService.credit_welcome_bonus: metal_welcome_ml = 33000, metal_welcome_credited = True, a third commit, idempotent on the flag (services/metal_service.py 30 to 51; WELCOME_BONUS_ML in core/metal_config.py line 39). Then, when ENABLE_EMAIL_VERIFICATION, a verification row whose token is returned to nobody (655 to 656).
  3. Workspace bootstrap. TenantService(db).create_workspace(name, owner_id, description="Personal workspace") is an async def, so the sync handler builds a fresh event loop, runs it, and closes the loop (auth_router.py 283 to 300). The service adds workspaces, workspace_users (role owner) and workspace_settings rows and commits the request session (services/workspace/tenant_service.py 34 to 106). Any exception is logged at WARNING and swallowed (301 to 303): a registration without a workspace succeeds, and /login's _ensure_workspace (176 to 232) repairs it at the first sign-in.
  4. Welcome mail, when transport_is_configured(): email_service.send_welcome_email(email, first_name or username) as a BackgroundTask (328 to 333).
  5. Verification mail, when ENABLE_EMAIL_VERIFICATION and the user is unverified: with a configured transport, a new EmailVerification row through create_email_verification(db, user.id) (auth_service.py 903 to 918: secrets.token_urlsafe(32), expires_at = now + 168 h, commit) and send_verification_email(email, token) queued (339 to 345); without one, a WARNING that names /resend-verification as the repair (346 to 351). The mail (email_service.py 807 to 850) links to {FRONTEND_URL}/verify-email?token=<token>; the DB template email_verification is consulted only when a db is passed, and the background task passes none, so the hard-coded German fallback is what ships (docstring 815 to 817).
  6. The answer. require_verification = REQUIRE_EMAIL_VERIFICATION and ENABLE_EMAIL_VERIFICATION and not user.is_verified is logged at WARNING with the user id and client IP (371 to 379, FIND-002 asked for registration volume to be an alerting signal) and decides between the 201 verification_required body (381 to 393) and the legacy branch: create_session(user_id, ip, ua, surface=LABS), _set_auth_cookies, tokens in the body (395 to 422).

verify_email(token) (auth_service.py 920 to 949): one SELECT for a row with this token, verified_at IS NULL and expires_at > now(); none is False (400 at the route); else verified_at = now(), users.is_verified = True, users.email_verified_at = now(), one commit. Other outstanding rows for the same user are left as they are.

resend_verification (auth_router.py 921 to 953): 404 when the feature is off; the user is looked up by lower-cased email; a new row and a queued mail only when the user exists, is unverified and the transport is configured; 202 with the same sentence in every case.

set_password_with_verification(db, token, new_password) (auth_service.py 1219 to 1257): the same token predicate as verify_email; sets password_hash, is_verified = True and verified_at, and does not set email_verified_at (1247 to 1256). The route is 204 or 400 (978 to 998).

Invariants

  • No session without a verified mailbox while both flags are on: register (371 to 393), login (491 to 502), verify-magic-link (1175 to 1185) and refresh_session (801 to 806) all ask is_verified before minting, and test_register_no_session_even_if_transport_down proves a dead transport does not reopen auto-login.
  • A verification_required answer carries no access_token, no refresh_token, no token_type, and sets no cookie (test_register_returns_no_tokens, test_register_sets_no_auth_cookies, test_login_unverified_sets_no_auth_cookies).
  • The grant rows are written by create_user before it returns, through surface_service.grant_surfaces and nothing else (B4 invariant), so user.surfaces in the 201 body already lists them.
  • Email uniqueness is the users.email UNIQUE constraint plus a pre-check on the lower-cased address; the pre-check's ValueError is the 400 a client sees, and only a race reaches the constraint's 500 branch. The reference pre-checks the address as received (591 to 596) and inserts it lower-cased (619), so at fd8558f a repeat whose local part differs in case is a deterministic 500 (Gotchas).
  • A username is unique, lower-case, and either the validated body value or the derived local part; the suffix loop stops at 100.
  • A verification token is single-use (verified_at is set on consumption and the predicate requires NULL) and expires after EMAIL_VERIFICATION_EXPIRE_HOURS.
  • /resend-verification answers 202 with one fixed sentence whether the address exists, is verified, or is unknown; the only distinguishing signal is the mail itself.
  • Every mail send is gated on transport_is_configured() and never on a provider key (auth_router.py 325 to 328, 339, 944; test_email_transport_funnel.py guards the rule elsewhere).
  • Registration never raises out of the workspace bootstrap or the mail sends; the user row is the only thing whose failure is a failed registration.
  • Registration writes no balance: nothing on users carries credit, and the welcome allowance is one row in the prepaid ledger (D2), written in the account transaction and idempotent per tenant, so a retried request cannot grant twice.

Frontend

identity-auth/ui/auth-screens          EmailVerification.tsx (/verify-email): reads ?token=, posts /verify-email,
                                       then shows a set-password form and a resend button with a cooldown;
                                       LoginAlchemy.tsx: the "check your inbox" state the 200 verification_required
                                       body asks for (not rendered at fd8558f, Gotchas)

The SPA has no registration page: App.tsx line 244 routes /register to /login, and neither authService.register (services/api.ts 111 to 118) nor AuthContext.register (contexts/AuthContext.tsx 224 to 244) has a caller in src/pages/. A service that copies this recipe ships the register form the inventory's F1 row describes, and the F1 demo's verification-required state renders the 201 body.

Minimum admin and debug surface: the WARNING lines registration: user=<id> ip=<ip> verification_required=<bool> and login: user=<id> ip=<ip> verification_required=<bool> (auth_router.py 376, 496); the WARNING register: email transport not configured (347 to 351) which is the first thing to look for when "the mail never came"; GET /api/auth/me (is_verified, surfaces); SELECT verified_at, expires_at FROM email_verifications WHERE user_id = :u ORDER BY created_at DESC to see whether a link was consumed. Admins verify by hand through B16's user status routes, not through this recipe.

Security & tenancy

Auth dependency: none on the four routes; they are the public front door and the endpoint's own checks are the control. Workspace derivation: the personal workspace is created for owner_id = user.id; nothing in the request can name a workspace. Fail direction, per seam: the verification gate closed (a dead transport, a failed send, a missing template all keep verification_required); the workspace bootstrap open (logged and swallowed, repaired at login); the welcome mail open (best effort); the rate limit open on a Redis blip (B18, rate_limit_service in-memory fallback).

Credentials at rest: passwords as bcrypt (cost 12, 72-byte truncation); verification tokens raw in email_verifications.token (Gotchas: hash them); the grant rows are the authorisation (B4). Rate limits: auth_rate_limit per IP per path on all four routes; there is no per-mailbox limiter on /resend-verification (Gotchas), whereas /request-magic-link adds enforce_email_rate_limit at 10 per minute per address (auth_router.py 1000 to 1007; rate_limit.py 34 to 35, 63 to 84).

Attacker-controlled inputs: email (validated as EmailStr, lower-cased, the enumeration oracle is the 400 "User with this email already exists" on register, accepted on purpose because the same fact leaks through any signup form; /resend-verification does not leak it); username (regex and length when given; derived from the local part when not); token on /verify-email and /set-password (43 characters of token_urlsafe(32), looked up by equality, 168-hour window, rate-limited per IP); first_name, last_name, company (length-bounded, rendered into the welcome mail and the workspace name "<first_name>'s Workspace", auth_router.py 285).

Tenancy of what is created: the user is global (no workspace column on users); the grants are per user and per surface; the workspace is owned by the user and is the tenant every later X-Workspace-ID resolves to (S6). A Labs registration is a Labs tenant with Prima grants; an API-only account (B14) is a Prima tenant with no labs grant, and the two never share a code path after create_user.

Tests

Backend (alchemy_labs/backend/tests/, run 2026-09-12 at fd8558f against the :5434 test Postgres with python -m pytest tests/test_register_verification.py tests/test_login_verification.py tests/test_refresh_verification.py -q --no-cov -o addopts="" -p no:cacheprovider): 1 failed, 18 passed in 16.37s. The one failure is test_login_verification.py::TestMagicLinkRequiresVerification::test_magic_link_after_verification_returns_token and is a harness artifact, not a product defect (Gotchas): the shared test session's identity map hands db.get(User, ...) (auth_router.py 1168) the stale is_verified=False instance the test itself loaded before verifying through a separate session.

  • test_register_verification.py (7 tests): test_register_returns_no_tokens (48), test_register_sets_no_auth_cookies (60), test_register_queues_verification_email (67: patches transport_is_configured to True and asserts one send_verification_email(email, token) call with a token longer than 20 characters; TestClient runs BackgroundTasks before returning), test_register_no_session_even_if_transport_down (82), test_login_after_verification_unaffected (91: mints a token through create_email_verification on a real session, posts /verify-email, then logs in and sees access_token), and the two legacy-flag cases (130, 139).
  • test_login_verification.py (8 tests): the 200 verification_required body without tokens (66), without cookies (83), the resend on a blocked login (94), wrong password still 401 (111), login after verification (121), the legacy flag (158), and the two magic-link cases (184, 202).
  • test_refresh_verification.py (4 tests): refresh refused at the service (77) and over HTTP as 401 (85), refresh after verification (95), the legacy flag (114).
  • Known-answer expectations a rebuilt service can assert without these fixtures: the 201 body has exactly the keys status, id, email, username, message, user; user.surfaces equals ["labs", "prima_chat", "prima_dev", "prima_web"] for a password registration; a second registration with the same email is 400 with the sentence above; /resend-verification for an unknown address is 202 with the sentence above.

Hermetic versus live: all three files are hermetic against the test database; the mail transport is patched. Playwright gate: none covers registration at fd8558f because the SPA has no register page; the F1 recipe's gate spec walks /verify-email with a token minted by the harness.

Deploy & ops

Config keys per app heredoc (S13): ENABLE_EMAIL_VERIFICATION=true, REQUIRE_EMAIL_VERIFICATION=true, EMAIL_VERIFICATION_EXPIRE_HOURS, EMAIL_TRANSPORT with its credentials (EMAIL_SMTP_HOST, EMAIL_SMTP_USERNAME, EMAIL_SMTP_PASSWORD, or RESEND_API_KEY), FRONTEND_URL (the host of the mailed link; wrong in one environment means every link points at another), MAX_SESSIONS_PER_USER. EMAIL_REDIRECT_ALL_TO diverts every outbound mail on a test host (email_service.py 80 to 88).

Migrations order: email_verifications and users are in baseline_prod_schema_001 (the table without its FK, 1031 to 1040); the fk_email_verifications_user_id_users ... ON DELETE CASCADE constraint the model declares is added by deferred_fk_phantom_tables_001 (migrations/versions/deferred_fk_phantom_tables_001.py 355 to 358); user_surface_grants, the ck_users_role CHECK and its backfill are surface_grants_001 (migrations/versions/20260802_0002_surface_grants.py) and must be applied before this code registers anyone, because create_user writes grants in the same call (models/__init__.py 80 to 84 registers the model so create_all() builds the table too). No migration owns a cleanup of expired verification rows; none exists at fd8558f.

Scheduler jobs: none. Argus checks: a synthetic registration on a staging host answers 201 with status == "verification_required" and no access_token; the transport WARNING (register: email transport not configured) count is zero over the window; SELECT count(*) FROM email_verifications WHERE verified_at IS NULL AND expires_at > now() stays bounded. Rollback: setting REQUIRE_EMAIL_VERIFICATION=false restores auto-login at register and login on the next process start, with the unverified live accounts the 2026-08-08 retest found (auth_router.py 485 to 490) as the reason not to; a code rollback past 7924389 (2026-08-08, fix(FIND-002): register() issues no session until email is verified) reopens FIND-002 at register, and past c240d83 (the same day, fix(auth): FIND-012) reopens the login and magic-link gate.

Gotchas

  • 2026-09-12 (measured at fd8558f, frontend): frontend/src/pages/EmailVerification.tsx posts /api/auth/verify-email with the token from the URL (43 to 54), then shows a set-password form and posts /api/auth/set-password with the same token (88 to 91). verify_email sets verified_at (auth_service.py 938), and set_password_with_verification requires verified_at IS NULL (1231), so the second call is always 400 "Invalid or expired verification token" and the page shows "Passwort konnte nicht festgelegt werden" to an account that is verified and already has the password it registered with. Fix: the page posts one of the two, /verify-email for a password registration and /set-password only for a passwordless account, and then routes to /login. No code changed in this wave (D8).
  • 2026-09-12 (measured at fd8558f, frontend): AuthContext.login (frontend/src/contexts/AuthContext.tsx 182 to 208) destructures access_token from every 200 and calls setTokens(undefined, undefined), sets Authorization: Bearer undefined and a user with emailVerified: true when the body is verification_required; grep -rn verification_required frontend/src returns nothing. The next /api/auth/me is 401 and the user sees a login that "worked" and then signed them out. Fix: branch on response.data.status === "verification_required" before touching tokens and render the inbox screen the inventory's F1 row asks for.
  • 2026-09-12 (measured at fd8558f, frontend): the SPA cannot register. App.tsx 244 redirects /register to /login; the two client functions that would post /api/auth/register disagree with the schema (services/api.ts 111 to 118 sends name, which UserRegister ignores; contexts/AuthContext.tsx 224 to 233 sends no password, which is a 422) and neither has a caller. Accounts reach Labs through admin creation (B16), desk sign-in (B20) and the API-only signup (B14). The recipe describes the route as built; the register screen is F1's to ship.
  • 2026-09-12 (measured at fd8558f): create_user pre-checks User.email == email with the address as received (auth_service.py 591 to 596) and inserts email.lower() (619). EmailStr lower-cases only the domain (pydantic 2.12.0 with email-validator 2.3.0, requirements.txt 31 and 80; in the Labs venv UserRegister(email='[email protected]', ...).email is [email protected]), so a second registration of an existing address with an upper-case letter in the local part passes the pre-check, hits the users.email UNIQUE constraint, raises IntegrityError rather than ValueError, and register() answers 500 "Registration failed" (auth_router.py 424 to 434) instead of 400. /resend-verification (auth_router.py 940) and /login (authenticate_user, auth_service.py 670) lower-case before their lookup and are not affected. Not a race. Fix: User.email == email.lower() in the pre-check (or compare on func.lower). No code changed in this wave (D8).
  • 2026-09-12 (measured at fd8558f, D2): create_user books the welcome allowance on the Metal tank (MetalService.credit_welcome_bonus, services/metal_service.py 30 to 51, users.metal_welcome_ml = 33000) and seeds the voucher-era quota columns (users.token_quota = 100000, auth_service.py 628 to 631), two of the ledgers D2 (2026-09-11) retires in favour of the prepaid credit in Prima's ledger; the inventory's B67 row lists users.metal_* and the token quota as legacy. B14 already books its welcome grant as a sparks_grants row in the account transaction (services/primaapi/identity.py 321 to 329). Fix: one welcome obligation against the prepaid ledger (B56) from both signup paths, MetalService and the quota writes removed from create_user. No code changed in this wave (D8).
  • 2026-09-12 (measured at fd8558f): models/__init__.py 80 to 83 says account creation writes grant rows in the same transaction as the user row. create_user commits the user at auth_service.py 632 and the grants at 648, then the welcome bonus at metal_service.py 48, then the verification row at 917. A failure between the first two commits leaves a committed user with no grant rows: allowed nowhere, /login 403 "This account is not permitted to sign in to Alchemy Labs.", /request-magic-link 202 and silent. Fix: write the grants before the first commit; until then the repair is backend/scripts/issue_magic_links.py --repair-grants (usage at its line 23) (wiki platform/architecture.md, "silent lockout", 6cce08b).
  • 2026-09-12 (measured at fd8558f): a password registration mints two verification rows, one dropped by create_user (655 to 656) and one mailed by register (340), and every blocked login and every resend adds another (128, 945). All of them stay valid for 168 hours, verify_email consumes only the one presented, and /set-password accepts any unused one to set a new password on an unauthenticated request. The token is stored raw in email_verifications.token, so a read of that table is a 7-day password-set credential for every unverified account; a password reset token (B6) lives 6 hours. Fix: store SHA-256, keep one outstanding row per user (invalidate the rest on issue and on consumption), and bound EMAIL_VERIFICATION_EXPIRE_HOURS to the reset token's 6.
  • 2026-09-12 (measured at fd8558f): /resend-verification has only the per-IP limiter (30 per minute per path); /request-magic-link also has enforce_email_rate_limit at 10 per minute per address (auth_router.py 1000 to 1007). A sender rotating IPs can flood one mailbox with verification mails, which is the case the per-email limiter was written for (rate_limit.py 29 to 33). Fix: Depends(<throttle>) on /resend-verification with scope="verify", and the same on /forgot-password.
  • 2026-09-12 (measured at fd8558f): UserRegister.password is min_length=8 and nothing else (auth_schemas.py 16), while /set-password, /reset-password and /change-password demand one uppercase, one lowercase and one digit (84 to 97, 106 to 115, 127 to 136). The weakest password on the estate is the one set at registration. Fix: one validator, shared.
  • 2026-09-12 (measured at fd8558f): register() catches Exception after create_user has committed (429 to 434). A 500 "Registration failed" from the workspace or mail stage leaves the account in place, and the client's retry is 400 "User with this email already exists" for an account whose owner never saw a success. Fix: return the 201 once create_user has returned, and let the later stages fail only their own log line (which the workspace stage already does).
  • 2026-09-12 (measured at fd8558f): set_password_with_verification sets is_verified but not email_verified_at (1251 versus verify_email's 946), so an account verified through the set-password path has email_verified_at IS NULL. Fix: set both in one place.
  • 2026-09-12 (measured at fd8558f): two workspace bootstraps exist, TenantService.create_workspace at register (auth_router.py 283 to 300, through services/workspace/tenant_service.py) and _ensure_workspace at login (176 to 232, raw SQL, with the desk-only refusal at 193 to 210 that the register path does not have). Register cannot create a desk-only account, so the gap is not exploitable at fd8558f; a new service keeps one bootstrap (B15's) and calls it from both places.
  • 2026-09-12 (measured at fd8558f): BCRYPT_ROUNDS (core/config.py 131) is read by nothing; hash_password uses bcrypt.gensalt() at its default of 12. Changing the setting changes nothing. Fix: pass rounds=settings.BCRYPT_ROUNDS or delete the setting.
  • 2026-09-12 (measured at fd8558f, harness): test_magic_link_after_verification_returns_token fails because the client fixture overrides get_db with the test's db_session (tests/conftest.py 146 to 167) and the test reads the User through it before verifying through a separate get_db_context() session; db.get(User, red.user_id) (auth_router.py 1168) then returns the identity-mapped instance with is_verified=False. In production every request has its own session and the handler reads the committed row. Fix in the test: db_session.expire_all() after the out-of-band verify.
  • 2026-09-12 (measured at fd8558f, code wins over the wiki): platform/architecture.md (6cce08b) "Authentication & Authorization" lists 2FA by email or SMS as enabled by default. The 2FA import, code generation and endpoints are commented out (auth_router.py 49 to 50, 261 to 263, 305 to 322, 504 to 597); registration is direct with a password; users.two_factor_enabled defaults to True and is read by nothing on the auth path.

Reference implementation

Paths are relative to each repo's root; the commit is measured_on.

  • alchemy_labs (branch d1a-token-source at fd8558f):
    • backend/api/routers/auth_router.py: _set_auth_cookies (59 to 79), _verification_required_response (88 to 114), _maybe_resend_verification (117 to 137), _require_labs_grant (140 to 152), _ensure_workspace (176 to 232), register (234 to 434), the verification gate in login (485 to 502), resend_verification (921 to 953), verify_email (955 to 976), set_password (978 to 998).
    • backend/services/auth_service.py: hash_password, verify_password (345 to 363), create_user (579 to 660), refresh_session's verification check (794 to 806), create_email_verification (903 to 918), verify_email (920 to 949), set_password_with_verification (1219 to 1257).
    • backend/models/user_model.py: USER_ROLES (35 to 42), User (57 to 155), EmailVerification (186 to 197).
    • backend/schemas/auth_schemas.py: UserRegister (11 to 29), EmailVerificationRequest, EmailVerificationConfirm, PasswordSetup (117 to 136).
    • backend/services/surface_service.py: grant_surfaces (60 to 92); backend/core/surfaces.py: LABS_SIGNUP_SURFACES, API_ONLY_SURFACES (182 to 189).
    • backend/services/email_service.py: transport_is_configured (64 to 77), send_welcome_email (669 onward), send_verification_email (807 to 850).
    • backend/services/metal_service.py: credit_welcome_bonus (30 to 51); backend/core/metal_config.py line 39 (legacy under D2; cited for what the reference does, not for what a new service writes). The prepaid path B14 uses instead: backend/services/primaapi/identity.py 321 to 329, backend/services/commerce/sparks_grants.py enqueue_welcome (253), backend/core/sparks_config.py 103.
    • backend/services/workspace/tenant_service.py: create_workspace (34 to 106).
    • backend/api/dependencies/rate_limit.py: auth_rate_limit (38 to 60), enforce_email_rate_limit (63 to 84).
    • backend/core/config.py: 43, 131, 440 to 441, 458 to 565, 845 to 852, 890.
    • backend/migrations/versions/baseline_prod_schema_001.py (1031 to 1040), backend/migrations/versions/deferred_fk_phantom_tables_001.py (355 to 358), backend/migrations/versions/20260802_0002_surface_grants.py.
    • backend/core/database.py (48 to 57, the pool); backend/requirements.txt (31, 80); backend/scripts/issue_magic_links.py (23).
    • backend/tests/test_register_verification.py, backend/tests/test_login_verification.py, backend/tests/test_refresh_verification.py, backend/tests/conftest.py (41, 146 to 167).
    • frontend/src/pages/EmailVerification.tsx (43 to 121), frontend/src/contexts/AuthContext.tsx (182 to 244), frontend/src/services/api.ts (111 to 118), frontend/src/App.tsx (244, 249).
  • Wiki philosophers_stone (6cce08b): platform/architecture.md ("Authentication & Authorization", "silent lockout"), operations/security-audit-2026-08-08-redteam-session.md (its finding #2, "Open self-registration returns a full session with NO email verification", line 24, is what the code calls FIND-002; the retest's FIND-012 exists only in commit c240d83's message and the comments at auth_router.py 485 to 490, a grep for either label over the wiki at 6cce08b returns nothing), operations/decisions/0020-prima-identity-and-prepaid-credits.md (section 1, the grant at signup), operations/prima-modules-inventory.md (row B1).

Deviations of the reference from this recipe, all dated 2026-09-12: the welcome bonus on the Metal ledger and the token-quota seed on the user row (D2); the case-sensitive email pre-check that turns a mixed-case repeat into a 500; the frontend's dead second call and its mis-parsed 200; no register page; four commits where the recipe wants one; duplicate and raw verification tokens with a 168-hour password-set window; no per-mailbox limiter on resend; the weaker registration password rule; the 500-after-commit branch; email_verified_at unset on one path; two bootstraps; the inert BCRYPT_ROUNDS; the stale 2FA row in the wiki.

Acceptance checks

Run from alchemy_labs/backend at the commit in measured_on with the :5434 test Postgres up, or from a new service built to this recipe. $B is the API base.

  1. python -m pytest tests/test_register_verification.py tests/test_refresh_verification.py -q --no-cov -o addopts="" -p no:cacheprovider prints 11 passed; python -m pytest tests/test_login_verification.py -q --no-cov -o addopts="" -p no:cacheprovider prints 7 passed and the one identity-map failure named in Gotchas (a new service, with its own harness, prints 8 passed).
  2. With both verification flags on: curl -s -i -X POST $B/api/auth/register -H 'content-type: application/json' -d '{"email":"a-<random>@example.com","password":"Abcdefg1"}' shows 201, no set-cookie: header, a body with "status":"verification_required" and no access_token, and "surfaces":["labs","prima_chat","prima_dev","prima_web"].
  3. Repeat the same body: 400 with "User with this email already exists". Repeat it with the local part upper-cased ("A-<random>@example.com"): the reference answers 500 "Registration failed" (the case-sensitive pre-check, Gotchas); a new service answers the same 400. Post with "password":"short": 422.
  4. curl -s -i -X POST $B/api/auth/login -d '{"email":<same>,"password":"Abcdefg1"}': 200, body "status":"verification_required", no access_token, no set-cookie:. With a wrong password: 401 "Invalid email or password".
  5. SELECT count(*) FROM email_verifications WHERE user_id = (SELECT id FROM users WHERE email = <same>) prints 3 after steps 2 and 4 with the transport configured (two at register, one at the blocked login) and 1 without one (only create_user's dropped row). The double mint is the reference's deviation; a new service that keeps one outstanding row per user prints 1 in both cases.
  6. Take the newest unused token from that table: curl -s -i -X POST $B/api/auth/verify-email -d '{"token":"<t>"}' is 204; repeat: 400 "Invalid or expired verification token"; SELECT is_verified, email_verified_at IS NOT NULL FROM users WHERE email = <same> prints t | t.
  7. Login again as in step 4: 200 with access_token, refresh_token, and two set-cookie: headers (access_token with Path=/api, refresh_token with Path=/api/auth, both HttpOnly).
  8. curl -s -i -X POST $B/api/auth/resend-verification -d '{"email":"nobody-<random>@example.com"}' is 202 with "If the email exists and is unverified, a verification link has been sent"; the same for the verified address from step 6 is the same 202 and the same sentence.
  9. 31 requests to POST /api/auth/register from one IP inside a minute: the 31st is 429 with a Retry-After header.
  10. ENABLE_EMAIL_VERIFICATION=false: POST /api/auth/verify-email and POST /api/auth/resend-verification are 404 "Email verification is not enabled". REQUIRE_EMAIL_VERIFICATION=false (with the first on): step 2 answers 201 with access_token and the two cookies, and SELECT count(*) FROM sessions WHERE user_id = <id> prints 1.
  11. grep -n "gensalt(" services/auth_service.py prints line 350 with no rounds= argument (the inert setting); grep -rn "BCRYPT_ROUNDS" --include='*.py' . | grep -v config.py | wc -l prints 0. A new service prints 1 for the second command and its gensalt call names the setting.
  12. SELECT metal_welcome_ml, metal_welcome_credited, token_quota FROM users WHERE email = <same> prints 33000 | t | 100000 on the reference (the D2 deviation); on a new service the columns do not exist and the welcome allowance is one row in the prepaid ledger for the account's workspace (B56's own checks say which table).
  13. grep -c "verification_required" frontend/src -r prints 0 at fd8558f; the F1 recipe's acceptance checks require it to be non-zero once its verification-required state ships.

Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.