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 |
|
| 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):
| Name | Default | Behaviour |
|---|---|---|
ENABLE_EMAIL_VERIFICATION | True (line 845) | off: /verify-email and /resend-verification answer 404, create_user mints no token, register never queues the verification mail |
REQUIRE_EMAIL_VERIFICATION | True (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_HOURS | 168 (440) | token lifetime; compare PASSWORD_RESET_EXPIRE_HOURS 6 (441) |
EMAIL_TRANSPORT | smtp (458), one of smtp, resend | ADR-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_KEY | unset | read only when the transport is resend; transport_is_configured() (services/email_service.py 64 to 77) is the send gate, never the key |
FRONTEND_URL | http://localhost:3000 (43) | the base of the mailed link /verify-email?token=<t> (email_service.py 821 to 822) |
BCRYPT_ROUNDS | 12 (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_USER | 5 (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.
| Route | Auth dep | Tenancy | Codes | Error detail strings |
|---|---|---|---|---|
POST /api/auth/register | none | creates 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; 500 | 400 "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-email | none | none | 204; 400; 404; 429 | 400 "Invalid or expired verification token"; 404 "Email verification is not enabled" |
POST /api/auth/resend-verification | none | none | 202 always for an enabled feature; 404; 429 | body {"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-password | none | none | 204; 400; 422; 429 | 400 "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:
- 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. auth_service.create_user(email, username, password, first_name, last_name, company)(auth_service.py579 to 660), inside its ownget_db_context(): an existing email isValueError("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 gets1,2, ... appended up to100, thenValueError(598 to 614); theUserrow is built withemail.lower()and the bcrypt hash and committed (616 to 633). Then the grant rows:LABS_SIGNUP_SURFACES(labs,prima_web,prima_chat,prima_dev) unlessrole == "api_only", which getsAPI_ONLY_SURFACES(core/surfaces.py185 to 189;auth_service.py639 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 (B56billing-credits/prepaid-credit-ledger, unwritten in this kit at 2026-09-12), before the first commit, the way B14 callssparks_grants.enqueue_welcome(db, tenant_id=<workspace>)inside the account transaction (services/primaapi/identity.py321 to 329;enqueue_welcomeatservices/commerce/sparks_grants.py253;WELCOME_SPARKSatcore/sparks_config.py103). 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 dayson the user row itself (628 to 631), and after the grantsMetalService.credit_welcome_bonus:metal_welcome_ml = 33000,metal_welcome_credited = True, a third commit, idempotent on the flag (services/metal_service.py30 to 51;WELCOME_BONUS_MLincore/metal_config.pyline 39). Then, whenENABLE_EMAIL_VERIFICATION, a verification row whose token is returned to nobody (655 to 656).- Workspace bootstrap.
TenantService(db).create_workspace(name, owner_id, description="Personal workspace")is anasync def, so the sync handler builds a fresh event loop, runs it, and closes the loop (auth_router.py283 to 300). The service addsworkspaces,workspace_users(roleowner) andworkspace_settingsrows and commits the request session (services/workspace/tenant_service.py34 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. - Welcome mail, when
transport_is_configured():email_service.send_welcome_email(email, first_name or username)as aBackgroundTask(328 to 333). - Verification mail, when
ENABLE_EMAIL_VERIFICATIONand the user is unverified: with a configured transport, a newEmailVerificationrow throughcreate_email_verification(db, user.id)(auth_service.py903 to 918:secrets.token_urlsafe(32),expires_at = now + 168 h, commit) andsend_verification_email(email, token)queued (339 to 345); without one, a WARNING that names/resend-verificationas the repair (346 to 351). The mail (email_service.py807 to 850) links to{FRONTEND_URL}/verify-email?token=<token>; the DB templateemail_verificationis consulted only when adbis passed, and the background task passes none, so the hard-coded German fallback is what ships (docstring 815 to 817). - The answer.
require_verification = REQUIRE_EMAIL_VERIFICATION and ENABLE_EMAIL_VERIFICATION and not user.is_verifiedis 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 201verification_requiredbody (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) andrefresh_session(801 to 806) all askis_verifiedbefore minting, andtest_register_no_session_even_if_transport_downproves a dead transport does not reopen auto-login. - A
verification_requiredanswer carries noaccess_token, norefresh_token, notoken_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_userbefore it returns, throughsurface_service.grant_surfacesand nothing else (B4 invariant), souser.surfacesin the 201 body already lists them. - Email uniqueness is the
users.emailUNIQUE constraint plus a pre-check on the lower-cased address; the pre-check'sValueErroris 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_atis set on consumption and the predicate requiresNULL) and expires afterEMAIL_VERIFICATION_EXPIRE_HOURS. /resend-verificationanswers 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.py325 to 328, 339, 944;test_email_transport_funnel.pyguards 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
userscarries 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: patchestransport_is_configuredtoTrueand asserts onesend_verification_email(email, token)call with a token longer than 20 characters;TestClientrunsBackgroundTasksbefore returning),test_register_no_session_even_if_transport_down(82),test_login_after_verification_unaffected(91: mints a token throughcreate_email_verificationon a real session, posts/verify-email, then logs in and seesaccess_token), and the two legacy-flag cases (130, 139).test_login_verification.py(8 tests): the 200verification_requiredbody 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.surfacesequals["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-verificationfor 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.tsxposts/api/auth/verify-emailwith the token from the URL (43 to 54), then shows a set-password form and posts/api/auth/set-passwordwith the same token (88 to 91).verify_emailsetsverified_at(auth_service.py938), andset_password_with_verificationrequiresverified_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-emailfor a password registration and/set-passwordonly 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.tsx182 to 208) destructuresaccess_tokenfrom every 200 and callssetTokens(undefined, undefined), setsAuthorization: Bearer undefinedand a user withemailVerified: truewhen the body isverification_required;grep -rn verification_required frontend/srcreturns nothing. The next/api/auth/meis 401 and the user sees a login that "worked" and then signed them out. Fix: branch onresponse.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.tsx244 redirects/registerto/login; the two client functions that would post/api/auth/registerdisagree with the schema (services/api.ts111 to 118 sendsname, whichUserRegisterignores;contexts/AuthContext.tsx224 to 233 sends nopassword, 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_userpre-checksUser.email == emailwith the address as received (auth_service.py591 to 596) and insertsemail.lower()(619).EmailStrlower-cases only the domain (pydantic 2.12.0 with email-validator 2.3.0,requirements.txt31 and 80; in the Labs venvUserRegister(email='Foo.Bar@Example.com', ...).emailisFoo.Bar@example.com), so a second registration of an existing address with an upper-case letter in the local part passes the pre-check, hits theusers.emailUNIQUE constraint, raisesIntegrityErrorrather thanValueError, andregister()answers 500"Registration failed"(auth_router.py424 to 434) instead of 400./resend-verification(auth_router.py940) and/login(authenticate_user,auth_service.py670) lower-case before their lookup and are not affected. Not a race. Fix:User.email == email.lower()in the pre-check (or compare onfunc.lower). No code changed in this wave (D8). - 2026-09-12 (measured at fd8558f, D2):
create_userbooks the welcome allowance on the Metal tank (MetalService.credit_welcome_bonus,services/metal_service.py30 to 51,users.metal_welcome_ml = 33000) and seeds the voucher-era quota columns (users.token_quota = 100000,auth_service.py628 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 listsusers.metal_*and the token quota as legacy. B14 already books its welcome grant as asparks_grantsrow in the account transaction (services/primaapi/identity.py321 to 329). Fix: one welcome obligation against the prepaid ledger (B56) from both signup paths,MetalServiceand the quota writes removed fromcreate_user. No code changed in this wave (D8). - 2026-09-12 (measured at fd8558f):
models/__init__.py80 to 83 says account creation writes grant rows in the same transaction as the user row.create_usercommits the user atauth_service.py632 and the grants at 648, then the welcome bonus atmetal_service.py48, then the verification row at 917. A failure between the first two commits leaves a committed user with no grant rows: allowed nowhere,/login403"This account is not permitted to sign in to Alchemy Labs.",/request-magic-link202 and silent. Fix: write the grants before the first commit; until then the repair isbackend/scripts/issue_magic_links.py --repair-grants(usage at its line 23) (wikiplatform/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 byregister(340), and every blocked login and every resend adds another (128, 945). All of them stay valid for 168 hours,verify_emailconsumes only the one presented, and/set-passwordaccepts any unused one to set a new password on an unauthenticated request. The token is stored raw inemail_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 boundEMAIL_VERIFICATION_EXPIRE_HOURSto the reset token's 6. - 2026-09-12 (measured at fd8558f):
/resend-verificationhas only the per-IP limiter (30 per minute per path);/request-magic-linkalso hasenforce_email_rate_limitat 10 per minute per address (auth_router.py1000 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.py29 to 33). Fix:Depends(<throttle>)on/resend-verificationwithscope="verify", and the same on/forgot-password. - 2026-09-12 (measured at fd8558f):
UserRegister.passwordismin_length=8and nothing else (auth_schemas.py16), while/set-password,/reset-passwordand/change-passworddemand 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()catchesExceptionaftercreate_userhas 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 oncecreate_userhas 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_verificationsetsis_verifiedbut notemail_verified_at(1251 versusverify_email's 946), so an account verified through the set-password path hasemail_verified_at IS NULL. Fix: set both in one place. - 2026-09-12 (measured at fd8558f): two workspace bootstraps exist,
TenantService.create_workspaceat register (auth_router.py283 to 300, throughservices/workspace/tenant_service.py) and_ensure_workspaceat 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.py131) is read by nothing;hash_passwordusesbcrypt.gensalt()at its default of 12. Changing the setting changes nothing. Fix: passrounds=settings.BCRYPT_ROUNDSor delete the setting. - 2026-09-12 (measured at fd8558f, harness):
test_magic_link_after_verification_returns_tokenfails because theclientfixture overridesget_dbwith the test'sdb_session(tests/conftest.py146 to 167) and the test reads theUserthrough it before verifying through a separateget_db_context()session;db.get(User, red.user_id)(auth_router.py1168) then returns the identity-mapped instance withis_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.py49 to 50, 261 to 263, 305 to 322, 504 to 597); registration is direct with a password;users.two_factor_enableddefaults toTrueand 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(branchd1a-token-sourceat 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 inlogin(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.pyline 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.py321 to 329,backend/services/commerce/sparks_grants.pyenqueue_welcome(253),backend/core/sparks_config.py103.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 atauth_router.py485 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.
python -m pytest tests/test_register_verification.py tests/test_refresh_verification.py -q --no-cov -o addopts="" -p no:cacheproviderprints11 passed;python -m pytest tests/test_login_verification.py -q --no-cov -o addopts="" -p no:cacheproviderprints7 passedand the one identity-map failure named in Gotchas (a new service, with its own harness, prints8 passed).- 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"}'shows201, noset-cookie:header, a body with"status":"verification_required"and noaccess_token, and"surfaces":["labs","prima_chat","prima_dev","prima_web"]. - Repeat the same body:
400with"User with this email already exists". Repeat it with the local part upper-cased ("A-<random>@example.com"): the reference answers500"Registration failed"(the case-sensitive pre-check, Gotchas); a new service answers the same400. Post with"password":"short":422. curl -s -i -X POST $B/api/auth/login -d '{"email":<same>,"password":"Abcdefg1"}':200, body"status":"verification_required", noaccess_token, noset-cookie:. With a wrong password:401"Invalid email or password".SELECT count(*) FROM email_verifications WHERE user_id = (SELECT id FROM users WHERE email = <same>)prints3after steps 2 and 4 with the transport configured (two at register, one at the blocked login) and1without one (onlycreate_user's dropped row). The double mint is the reference's deviation; a new service that keeps one outstanding row per user prints1in both cases.- Take the newest unused token from that table:
curl -s -i -X POST $B/api/auth/verify-email -d '{"token":"<t>"}'is204; repeat:400"Invalid or expired verification token";SELECT is_verified, email_verified_at IS NOT NULL FROM users WHERE email = <same>printst | t. - Login again as in step 4:
200withaccess_token,refresh_token, and twoset-cookie:headers (access_tokenwithPath=/api,refresh_tokenwithPath=/api/auth, bothHttpOnly). curl -s -i -X POST $B/api/auth/resend-verification -d '{"email":"nobody-<random>@example.com"}'is202with"If the email exists and is unverified, a verification link has been sent"; the same for the verified address from step 6 is the same202and the same sentence.- 31 requests to
POST /api/auth/registerfrom one IP inside a minute: the 31st is429with aRetry-Afterheader. ENABLE_EMAIL_VERIFICATION=false:POST /api/auth/verify-emailandPOST /api/auth/resend-verificationare404"Email verification is not enabled".REQUIRE_EMAIL_VERIFICATION=false(with the first on): step 2 answers201withaccess_tokenand the two cookies, andSELECT count(*) FROM sessions WHERE user_id = <id>prints1.grep -n "gensalt(" services/auth_service.pyprints line 350 with norounds=argument (the inert setting);grep -rn "BCRYPT_ROUNDS" --include='*.py' . | grep -v config.py | wc -lprints0. A new service prints1for the second command and itsgensaltcall names the setting.SELECT metal_welcome_ml, metal_welcome_credited, token_quota FROM users WHERE email = <same>prints33000 | t | 100000on 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).grep -c "verification_required" frontend/src -rprints0at fd8558f; the F1 recipe's acceptance checks require it to be non-zero once itsverification-requiredstate 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.