Module kit / Identity and auth
Magic-link login
identity-auth/magic-link-login
| Kind | backend |
|---|---|
| Domain | identity-auth |
| Maturity | prod |
| Reusable | extract |
| Stack | fastapi, sqlalchemy-sync, postgres, redis, smtp |
| Depends on | _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/error-envelope, _shared/test-layout |
| Frontends | identity-auth/ui/auth-screens, identity-auth/ui/chat-signin-and-workspace-picker, identity-auth/ui/developer-portal-auth-pages |
| Reference implementation |
|
| Measured on | alchemy_labs d1a-token-source fd8558f 2026-09-12 |
| Also measured on |
|
| Version | 1 |
Purpose
A person types an address, receives one link, clicks it within fifteen minutes and holds a session on exactly one surface, with no password anywhere in the exchange. The invariant that makes it trustworthy is that the surface a link opens is decided when the link is minted and written on the token row, checked against user_surface_grants at mint and again at redemption, and never read from the redeeming request: a link mailed for prima_chat cannot be turned into a labs session by editing a body, and an account without a grant gets the same 202 as everyone else and no mail.
This recipe fixes the two routes, the magic_links row, the surface resolution order, the template chain per surface, the always-202 contract with its four silent failure modes, and the in-process script that diagnoses them.
When to use
The default sign-in on every surface (F1 makes it the primary method; F6 and F7 have no other). Trigger phrases: "passwordless login", "link sent but nothing arrived", "silent lockout", "which door does this link open", "the link lands on the wrong host", "issue a login link by hand", "acting email on a shared account".
Do not use it for the session mechanics after redemption (identity-auth/session-login-refresh-logout, B2: create_session is where this recipe hands over), for the grant table (identity-auth/surface-registry-and-gate, B4), for the domain sign-in that mints a link for a named account from a proven domain (identity-auth/desk-domain-sign-in, B20, which rides on create_magic_link_for_user and login_email here), for the per-IP and per-address limits themselves (identity-auth/rate-limiting, B18), or for the review pipeline's reviewer token, which is its own table and not a magic link (the wiki's auth table still says magic links are "used for review pipeline doctor access"; see Gotchas).
Stack assumptions
Pinned as measured at fd8558f (backend/requirements.txt): fastapi==0.118.2, SQLAlchemy==2.0.43 (sync sessions from core/database.py::get_db_context), redis==6.4.0 (the rate-limit buckets, in-memory fallback when down), resend==2.7.0 or SMTP through services/email_service.py::_send_email as EMAIL_TRANSPORT selects (ADR-0023), email-validator==2.3.0 behind EmailStr. Foundations assumed: S4 (the router is mounted with mount_router(app, auth_router.router, prefix="/api/auth", surfaces=ALL_SURFACES), app_fastapi.py line 820), S5 (create_session and the grant check), S7 (the 400 and 429 bodies are the Labs envelope), S9.
Environment names read on this path and their fail behaviour (core/config.py):
| Name | Default | Behaviour |
|---|---|---|
EMAIL_TRANSPORT | smtp | smtp or resend; validated at load (493 to 512); smtp declared without EMAIL_SMTP_HOST, EMAIL_SMTP_USERNAME and EMAIL_SMTP_PASSWORD raises, defaulted without them falls back to resend with a warning (515 to 565) |
RESEND_API_KEY | empty | what transport_is_configured() checks when the transport is resend (email_service.py 64 to 76) |
EMAIL_SMTP_HOST, EMAIL_SMTP_USERNAME, EMAIL_SMTP_PASSWORD | host default, empty, empty | what it checks when the transport is smtp |
FRONTEND_URL | http://localhost:3000 | the link base for labs in development only (auth_router.py 1090 to 1092); every other case uses the surface's canonical origin |
ENVIRONMENT | development | in development a minted link is also logged at INFO (1118 to 1120) |
REQUIRE_EMAIL_VERIFICATION, ENABLE_EMAIL_VERIFICATION | both True | with both on, redeeming a link for an unverified account answers verification_required and no tokens (1175 to 1186) |
TRUSTED_PROXY_COUNT | 1 | which X-Forwarded-For hop the per-IP bucket keys on (utils/net.py 29 to 41) |
MAX_SESSIONS_PER_USER | 5 | trimmed inside create_session (S5) |
No transport configured is not an error: the route answers 202 and sends nothing (Application logic step 8). A new service copies that contract and the script that makes it diagnosable, or it will spend a day on "staging does not send email".
Data model
One table, owned here; the columns as declared in models/user_model.py lines 213 to 242, the DDL from migrations/versions/baseline_prod_schema_001.py (1205 to 1213, 2528, 3823), 20260802_0002_surface_grants.py (73 to 80) and 20260905_0001_desk_domain_login.py (55 to 70):
magic_links (
id uuid PRIMARY KEY, -- pk_magic_links
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token varchar(255) NOT NULL, -- secrets.token_urlsafe(32), stored verbatim
used boolean, -- NULL on legacy rows reads as false
expires_at timestamp NOT NULL, -- naive UTC, created + 15 min
created_at timestamp,
used_at timestamp,
surface varchar(32) NOT NULL DEFAULT 'labs', -- which door this link opens; backfilled 'labs'
login_email varchar(320) NULL, -- the address mailed, only when it differs from users.email
login_workspace_id uuid NULL REFERENCES workspaces(id) ON DELETE SET NULL, -- fk_magic_links_login_workspace; the domain rule that minted it
next_path varchar(512) NULL -- relative path only, validated at request time
);
CREATE UNIQUE INDEX ix_magic_links_token ON magic_links(token);
No CHECK on surface: the value is validated in code (assert_issuable before insert, ISSUABLE_SURFACES at redemption, auth_service.py 1056 and 1174 to 1181). No index on expires_at or used, and nothing deletes rows (grep over backend/ outside tests and migrations hits only the two service methods and the desk sign-in's budget count, services/service_desk/signin.py 202 to 206), so the table grows by one row per request that passed the grant check, forever.
What is not stored: the request's IP or user agent (those land on sessions at redemption), the surface the request claimed (only the resolved one), the Host header, the mail transport's message id, and any hash of the token (it is the token). The response of POST /request-magic-link carries nothing that identifies the row.
The value that crosses from the service to the router is Redemption (auth_service.py 307 to 325): user_id, surface, login_email, login_workspace_id, next_path, all ids and strings, because the service loads the user in its own session and returning the ORM instance handed the router a detached object (the docstring records the bug).
API surface
Both routes live on auth_router at /api/auth, mounted on ALL_SURFACES, so a request from any surface's origin reaches them; the surface question is answered inside.
| Route | Auth dep | Tenancy | Codes | Error codes and bodies |
|---|---|---|---|---|
POST /api/auth/request-magic-link | none; auth_rate_limit (30 per minute per client IP, keyed by path) and _magic_link_email_throttle (10 per minute per normalised address, scope magic-link) | none | 202 always once the dependencies pass, body {"message": "If the email exists, a magic link has been sent"} (1124) | 400 "Unknown surface '<x>'" for an explicit surface outside ISSUABLE_SURFACES (_requested_surface, 164 to 168); 422 for a malformed email or a next that is not a relative path (schemas/auth_schemas.py 138 to 157: ^/(?!/)[A-Za-z0-9/_\-.?=&%]*$, max 512); 429 from either limiter (api/dependencies/rate_limit.py 38 to 60, 63 to 92), body {"error": "Too many requests for this email. Please try again later.", "code": "RATE_001", ...}. Both dependencies raise Retry-After (59, 89) and at fd8558f the envelope handler drops it, so the wire carries no such header (Gotchas) |
POST /api/auth/verify-magic-link | none, no rate limit | none | 200 {access_token, refresh_token, token_type, surface, expires_in, acting_email, next, desk_only, user} (1243 to 1273) or 200 {status: "verification_required", ...} with no tokens and no cookies (88 to 115) | 400 "Invalid or expired magic link" for unknown, used, expired, revoked-grant, inactive-user and unknown-surface tokens alike (1162 to 1173); 403 "This account is not permitted on this surface." when create_session raises SurfaceAccessDenied (1233 to 1237, reachable only if the grant vanished between the service's check and the mint); 503 "Access control is temporarily unavailable." when the desk-only question cannot be answered (1210 to 1216) |
Request bodies: MagicLinkRequest {email: EmailStr, surface?: str, next?: str}; MagicLinkVerify {token: str}. Both Prima clients also send surface on verify (prima-chat/server/identity.ts 250 to 252, prima-developer/src/lib/auth.ts 334 to 337); the schema has no such field and Pydantic drops it, which is the intended shape: the surface at redemption is the row's. The user object in the 200 carries surfaces: sorted(get_user_surfaces_standalone(user.id)) (1267), the frontend's authorisation contract (S2), and client_status.
The 200 also sets the two HttpOnly cookies through auth_router._set_auth_cookies (59 to 80): access_token on /api, refresh_token on /api/auth, SameSite=Lax, no Domain, Secure in production. That is the Labs-shaped helper, not the surface-aware core/session_cookies.py::set_auth_cookies that the passkey router uses (Gotchas).
Application logic
request_magic_link (auth_router.py 1007 to 1124), in order:
- Dependencies:
auth_rate_limit(per IP, bucketrate_limit:auth:/api/auth/request-magic-link:<ip>), then_magic_link_email_throttle, which re-reads the body model and callsenforce_email_rate_limit(email, scope="magic-link")(1000 to 1004). A 429 from either is raised withRetry-After(rate_limit.py59, 89) and reaches the client without it at fd8558f, because the envelope handler never readsexc.headers(Gotchas, and B18, which owns the measurement); a service built to this recipe forwards the header. _requested_surface(request, body.surface)(155 to 173): an explicit id wins and must be issuable (else 400); otherwisesurface_for_host(Host)(core/surfaces.py238 to 248, exact hostname match against each surface'shosts); otherwiselabs. The answer is a request, not a decision.- Desk domain sign-in, only when the surface is
labs(1030 to 1055):desk_signin.resolve_login_emailmay return a resolution for an address at a proven domain; any exception falls back toNonewith a rollback. Everything below theNonebranch is this recipe; the other two branches are B20 and end increate_magic_link_for_user(1075 to 1081) withlogin_email,next_pathandlogin_workspace_id. auth_service.create_magic_link(email, surface=surface_id)(auth_service.py1046 to 1086):assert_issuable(surface); in its own session, load the user by lowercased email;Nonewhen missing or inactive;Nonewhensurface not in get_user_surfaces(db, user.id)(logged at WARNINGRefusing magic link for user <id> on surface <s>: no grant); otherwise insert the row withtoken = secrets.token_urlsafe(32),surface,expires_at = utcnow + 15 min, commit, return the token.base_url = surface.origin; indevelopmentand only forlabs,FRONTEND_URLinstead (1090 to 1092).- If a token exists and
transport_is_configured():background_tasks.add_task(email_service.send_magic_link_email_task, email, token, base_url, surface_id, template_override)(1109 to 1116). The task runs after the response. - Else if a token exists and
ENVIRONMENT == "development": log the full URL at INFO (1117 to 1120). - Return 202 with the fixed message, whatever happened in 3 to 7.
send_magic_link_email_task (email_service.py 579 to 608) opens its own get_db_context() session, because the request's session is closed by the time a BackgroundTask runs; before it existed the route queued send_magic_link_email with db=None and the template branch was unreachable (the docstring records it). It calls send_magic_link_email (493 to 577): URL <base_url>/auth/magic-link?token=<token>; with a session, _resolve_magic_link_template walks magic_link_template_names(surface, template_override) (447 to 479: the override first, then magic_link__<surface>, then magic_link__prima for a Prima surface, then magic_link) and the first existing row wins; an inactive row means no send and False; no row means the hard-coded German fallback. _send_email (855 to 882) routes to SMTP or Resend and returns a bool, never raises. A database error in the task falls back to the template-less send (598 to 608).
verify_magic_link (auth_router.py 1126 to 1273), in order:
auth_service.verify_magic_link(token)(auth_service.py1147 to 1217): in its own session, select the row wheretokenmatches,used == Falseandexpires_at > utcnow;Noneotherwise.surface = row.surface or "labs"; not inISSUABLE_SURFACESisNonewith an ERROR log. Load the user; missing or inactive isNone.surface not in get_user_surfaces(db, user.id)isNonewith a WARNING (redeemed after grant revoked). Then markused = True,used_at,user.last_login, commit, return theRedemption.- A
Nonefrom step 1 is tried as a desk sign-in intent (desk_signin.redeem_intent, 1151 to 1159); stillNoneis the uniform 400. - Re-select the user in the request's session (
db.get(User, red.user_id), 1168); missing or inactive is the same 400. - With both verification flags on and
not user.is_verified: best-effort resend of the verification mail and return theverification_requiredbody, no tokens, no cookies (1175 to 1186). Every redemption is logged at WARNING with user, surface and the flag (1180 to 1183). - If
red.login_emaildiffers fromusers.email, record the login binding (B20, 1191 to 1204); failures are logged and never cost the login. - Ask
desk_access.is_desk_only_user(1210 to 1216);DeskAccessUnavailableis 503, because_ensure_workspaceswallows its own errors and a wrong "not desk-only" would create a workspace the account must never own. Not desk-only (1217 to 1218):_ensure_workspace(user)(176 to 232) creates a personal workspace when the user owns none. create_session(user_id, ip, ua, surface=red.surface, acting_email=<login_email when it differs>)(1225 to 1237);SurfaceAccessDeniedis 403.- Cookies, then the 200 body with
surface,acting_email,next,desk_onlyanduser.
Invariants
- The surface a link opens is the row's
surface, written at mint and read at redemption; the redeeming request has no field that can change it (MagicLinkVerifyhastokenonly;test_magic_link_surface_comes_from_the_token_row_not_the_request,tests/test_surface_scoped_access.py501). - The grant is checked twice, at mint (
create_magic_link1066 to 1073) and at redemption (verify_magic_link1194 to 1201), and a third time insidecreate_session; revoking a grant between the mail and the click is a 400, not a session. POST /request-magic-linkanswers 202 with one fixed body for an unknown address, an inactive account, an account without the grant and a successful mint (test_request_magic_link_over_http_never_leaks_which_surface_is_allowed, 513); the only non-202 answers are the two limiters, a malformed body and an unknown explicit surface.- A token is single-use (
usedflipped inside the same transaction that returns theRedemption) and expires 15 minutes after mint (auth_service.py1081, 1141); there is no refresh of a link. - A
nextis a relative path or nothing (validate_next,auth_schemas.py149 to 156); an absolute URL or//hostnever reaches the row, so the login mail is not an open redirect. login_emailis stored only when it differs fromusers.email(create_magic_link_for_user1131 to 1133) and becomesaemon the session, never a permission (S5).- The mail goes to the address that asked, never to the account's own address when they differ (
request_magic_link1070 to 1074 and the comment above it), because the asking inbox is the only proof anyone has. - The service returns ids, not ORM objects, across the session boundary (
Redemption); the router re-selects. - A link mailed for a Prima surface points at that surface's canonical origin (
surface.origin), never atFRONTEND_URL(test_prima_magic_link_on_the_wire,tests/integration/test_smtp_capture.py252). - The transport gate is
transport_is_configured(), never the presence of one vendor's key (auth_router.py1094 to 1109;tests/unit/test_email_transport.py::test_magic_link_goes_through_the_selected_transport, 269).
Frontend
The frontend modules that render this recipe, by id, and what each shows of it:
identity-auth/ui/auth-screens LoginAlchemy posts /request-magic-link with no surface (Host and the
labs default decide), renders a 2xx as "link sent" and every non-ok
answer, 429 and 400 alike, as one generic German string, reading
neither the status nor Retry-After (frontend/src/pages/
LoginAlchemy.tsx 151 to 165); MagicLinkCallback posts
/verify-magic-link with the token from ?token=, stores
data.access_token and data.refresh_token, and routes on next and
desk_only through signInDestination (MagicLinkCallback.tsx 48 to 81,
lib/deskLogin.ts). Neither screen has a verification_required branch
(Gotchas)
identity-auth/ui/chat-signin-and-workspace-picker
SignIn.tsx -> the chat server's requestMagicLink/verifyMagicLink,
which send surface: prima_chat on both calls and map 400 "unknown
surface" to an operator error, not a user one (prima-chat
server/identity.ts 188 to 262); no token reaches the browser (B21)
identity-auth/ui/developer-portal-auth-pages /anmelden posts with surface: prima_dev, /auth/magic-link redeems and
keeps the token in sessionStorage (prima-developer src/lib/auth.ts
248 to 258, 330 to 340)
service-desk/ui/desk-client-portal (F57, not a file yet) the du-register mail template magic_link__desk,
the acting-email banner and the next deep link
Minimum admin and debug surface: the four WARNING lines this path emits (Refusing magic link ... no grant, redeemed after grant revoked, verify-magic-link: user=... surface=... verification_required=..., and the transport's failure line), and scripts/issue_magic_links.py (--check diagnoses transport, grant, active flag and surface per address without minting; without --check it mints in-process and prints the URL the mail would have carried, valid 15 minutes, to be handed over one to one). There is no admin table of magic_links rows and no route that lists them.
Security & tenancy
Auth dependency: none on either route; the person is identified by control of the mailbox, and the session that results is S5's. Workspace derivation: none here; _ensure_workspace creates the first workspace for a non-desk account, and the token carries tid only when unambiguous (S5). Fail direction: grant check closed (three times); desk-only question closed (503); mail transport open (202 and a log line, by design, so that a mail outage cannot become an enumeration oracle); rate limiters open when Redis is down (in-memory fallback per process, B18).
Credentials at rest: magic_links.token is the 43-character token_urlsafe(32) value stored verbatim, so a read of the table is a read of every live login link for the next fifteen minutes; the same is true of sessions.access_token (S5). A new service that wants better stores sha256(token) in the column and compares hashes, the way api_keys.key_hash does, at the cost of the unique index on the raw value; the reference does not. In development the full URL is logged.
Rate limits: 30 per minute per client IP on the request route, keyed by path so login and magic link do not share a bucket; 10 per minute per address, so a rotating-IP flood of one mailbox stops at ten mails a minute (rate_limit.py 20 to 35, the comment explains why the IP ceiling was raised from 10 on 2026-09-05, e895e7d). The verify route has no limiter: the token space is 256 bits and a miss is a 400.
Attacker-controlled inputs: email (validated, lowercased, used as a lookup key and as the mail recipient); surface (must be in ISSUABLE_SURFACES or 400; never trusted past the grant check); next (regex-bound relative path); Host (used only to pick a default surface, and only when the API is served under a surface's own hostname; on a separate API host it always falls through to labs); token at verify (looked up by equality on a unique index). Nothing from the request is interpolated into the mail except the token in the URL.
Tenancy of the link: a row belongs to one user and one surface; login_workspace_id names the domain rule that minted it and is never re-derived at click time (model comment, 229 to 235). A link for labs cannot be minted for an api_only account because that role never holds the labs grant (test_magic_link_for_labs_is_refused_for_api_only, 471), which is the breach ADR-0020 closed.
Tests
Backend (alchemy_labs/backend/tests/, run 2026-09-12 at fd8558f against the :5434 test Postgres with python -m pytest <file> -q --no-cov -o addopts="" -W ignore):
test_surface_scoped_access.py:44 passed in 7.28s. Load-bearing here:test_magic_link_for_labs_is_refused_for_api_only(471),test_magic_link_for_prima_works_and_stays_on_prima(480: mint forprima_chat, redeem, mint a session, decode withexpected_surface="prima_chat"and readaud),test_magic_link_surface_comes_from_the_token_row_not_the_request(501, reads the row),test_request_magic_link_over_http_never_leaks_which_surface_is_allowed(513: refused and allowed answer202with equal bodies). A first run of the file printed11 passed, 33 errors; the rerun was green and the two later reruns stayed green, so treat a red first run as the harness and run it again before believing it.test_login_verification.py::TestMagicLinkRequiresVerification:7 passed, 1 failed.test_magic_link_unverified_returns_no_token(184) passes:verification_required, no tokens, no cookies.test_magic_link_after_verification_returns_token(202) fails at fd8558f: the test verifies the address throughverify_email, which commits in its ownget_db_context()session (auth_service.py920 to 949), then redeems through the test client, whose request session is the fixture's transactional session (tests/conftest.py146 to 167) and still holds the pre-verificationUserin its identity map, so the router readsis_verified == Falseand answersverification_required. A production request has a fresh session; the failure is the harness's (S9 Gotchas), not the route's.test_desk_domain_signin.py(-k "request_magic_link or verify_magic_link"):3 passed: the named-account mint mails the asker (720), an unknown domain leaves the route unchanged (760), redemption stamps the acting email and creates no workspace (799).test_desk_domain_login_schema.py::test_magic_link_can_record_the_address_it_was_mailed_to(405) passes among4 passedfor-k magic_link.tests/unit/test_email_transport.py(-k magic):15 passed, includingtest_magic_link_goes_through_the_selected_transport(269).tests/integration/test_smtp_capture.py:2 passed,test_prima_magic_link_on_the_wire(252) andtest_labs_magic_link_is_unchanged_on_the_wire(297) read the actual message off a capturing SMTP server: the known-answer vectors for the template chain and the link base.test_webauthn_multi_rp.py::test_magic_link_base_url_follows_the_surface(847) pinslink_base_for, which the route does not call (Gotchas).test_critical_endpoints.py::TestMagicLinkEndpoints(353 to 396):14 failed, 33 passedfor the file at fd8558f, every failure anUndefinedTablefrom the harness (relation "magic_links" does not existin the captured log), so the four cases there are not evidence either way.
Hermetic versus live: everything above is hermetic; the SMTP capture is a local server. Playwright: frontend/e2e/critical-flows.spec.ts line 22 (magic link request endpoint works) posts the request route and expects 202; there is no browser walk of the redemption because it needs a mailbox.
Known-answer vectors a rebuilt service must reproduce: the 202 body string; the 400 string Invalid or expired magic link; the URL shape <origin>/auth/magic-link?token=<43 url-safe chars>; the template order [override, magic_link__<surface>, magic_link__prima (Prima only), magic_link]; a redeemed row with used = true and used_at set and a second redemption answering 400.
Deploy & ops
Config keys per app heredoc (S13, alchemycloud b3d34da): EMAIL_TRANSPORT and the EMAIL_SMTP_* triple, RESEND_API_KEY (still present beside smtp on the production heredoc for callers that bypass the selector), FRONTEND_URL, REDIS_URL (the limiters), TRUSTED_PROXY_COUNT, the two verification flags, MAX_SESSIONS_PER_USER. The production heredoc (lines 215 to 361) and the staging one (362 to 551) both carry FRONTEND_URL, RESEND_API_KEY and EMAIL_TRANSPORT.
Migrations order: baseline_prod_schema_001 (the table), surface_grants_001 (surface, backfilled 'labs', must land before any mint that names a surface), 20260905_0001_desk_domain_login (login_email, login_workspace_id, next_path, before c591c90's router). All three are in the chain at fd8558f (S8).
Scheduler jobs: none. No job purges magic_links; a new service adds a daily DELETE ... WHERE expires_at < now() - interval '7 days' under S12 and an index on expires_at, and the reference should (Gotchas).
Argus checks: POST /api/auth/request-magic-link with a syntactically valid unknown address answers 202 within the limiter; the mail transport's own check (S13). A silent lockout is invisible to a health check by design: the signal is the WARNING line Refusing magic link for user ... no grant in the application log, and the runbook is scripts/issue_magic_links.py --check --email <addr> --surface <id> run inside the container.
Rollback: the routes are stateless apart from the row; a redeploy of the previous image keeps every unexpired link valid because the token is the row and the surface column has a server default. Reverting surface_grants_001 would delete the surface column and every link would open labs, which is the pre-ADR-0020 breach; do not.
Gotchas
- 2026-09-12 (measured at fd8558f, D8 gotcha): the Labs SPA treats the
verification_required200 as a successful sign-in.verify_magic_linkanswers that body with 200, no tokens and no cookies (auth_router.py1175 to 1186), andMagicLinkCallback.tsxtestsresponse.okonly (56), then writesdata.access_tokenanddata.refresh_tokenintolocalStorage(63 to 64), callssignInDestination(data)(69) and shows the success animation (81);grep -rn verification_required frontend/srcprints nothing. An unverified Labs account therefore stores the stringundefinedas its access token, is navigated to/dashboard, and sees a failed/meinstead of "confirm your address". Fix: branch ondata.status === "verification_required"before the twolocalStoragewrites and render the resend state. The request screen is unbranched in the same way:LoginAlchemy.tsx159 to 160 turns every non-ok response intoFehler beim Senden des Login-Linksand reads neitherresponse.statusnorRetry-After, so the 429 and the 400 for an unknown surface are one sentence. Fix: read the status. F1 (identity-auth/ui/auth-screens) and the wiki's inventory row F1 both describe these two states as rendered; the correction belongs in those files too and is not made here. - 2026-09-12 (measured at fd8558f, D8 gotcha): neither limiter's
Retry-Afterreaches the client.auth_rate_limitandenforce_email_rate_limitraise 429 with the header (rate_limit.py59 and 89), andhttp_exception_handler(app_fastapi.py724 to 788) builds both of its answers (return JSONResponse(at 754 and 779) withheaders=_error_cors_headers(request)(765 and 787) and never readsexc.headers. Measured withTestClient(app_fastapi.app): tenPOST /api/auth/request-magic-linkfor one address answer 202 and the eleventh answers 429 with{"error":"Too many requests for this email. Please try again later.","code":"RATE_001",...}and noretry-afteramong its headers.identity-auth/rate-limiting(B18) owns the measurement across all four tiers; only the middleware tier's rawJSONResponsecarries the header. Fix:headers={**_error_cors_headers(request), **(exc.headers or {})}at both return sites. A new service built to this recipe forwards the header, which is what the login screen needs to say how long the wait is. - 2026-09-12 (measured at fd8558f): the link base for
labsoutsidedevelopmentis the canonical originsurface.origin(auth_router.py1090 to 1092), notFRONTEND_URL;core/surfaces.py::link_base_for(327 to 339), which would useFRONTEND_URLfor Labs, has zero callers outside tests (grep -rn link_base_for backend --include='*.py'hits its definition, its docstring mention andtest_webauthn_multi_rp.py847). A Labs magic link minted on staging therefore points at the production host, and the wiki's silent-lockout note (platform/architecture.md, 2026-09-04) tells the operator to swap the host by hand. Fix: calllink_base_for(surface)at line 1090 and delete thedevelopmentspecial case; the test at 847 already pins the function. - 2026-09-12 (measured at fd8558f):
scripts/issue_magic_links.py --repair-grantsgrantsAPI_ONLY_SURFACES(the three Prima surfaces) whatever--surfacewas asked for (lines 127 to 131) and does not re-check the requested surface afterwards, so--surface labs --repair-grantson an account without thelabsgrant printsgranted [...]and thencreate_magic_link returned None despite passing every check above. The wiki's runbook (platform/architecture.md, "silent lockout") shows exactly that invocation. Fix: grant{surface_id}(or refuse to repairlabs, which is a deliberate decision per ADR-0020) and re-read the grants before minting. - 2026-09-12 (measured at fd8558f, code wins over the wiki):
platform/architecture.md(6322fa3) auth table says magic links are "Used for review pipeline doctor access". Since fb7159f (2026-08-02) they are the primary sign-in on every surface (LoginAlchemy.tsx, F6, F7) and the review pipeline uses its own token table (B127). - 2026-09-12 (measured at fd8558f): two cookie helpers exist. This route and
/loginset cookies throughauth_router._set_auth_cookies(59 to 80: noDomain,SameSite=Lax), the passkey routes throughcore/session_cookies.py::set_auth_cookies(64 to 113: the surface'scookie_domainwhen same-site,SameSite=None; Securewhen cross-site). Aprima_chatmagic-link redemption from the chat server gets Labs-shaped cookies it cannot use; harmless because the chat server keeps the body tokens server-side (B21), but the docstring "one helper, used by every path that hands out a session" is false. Fix: callset_auth_cookies(response, a, r, surface=get_surface(surface_id), request=request)at line 1240. - 2026-09-12 (measured at fd8558f):
verify_magic_linklogs every redemption at WARNING (1180 to 1183), including the successful ones, so the WARNING channel that carries the two real refusals is noisy on a busy day. Fix: INFO for the success branch. - 2026-09-12 (measured at fd8558f):
magic_linksandpassword_resetsrows are never deleted;usedandexpires_atare the only state. Nothing reads old rows, so the cost is table size only. Fix: the S12 purge job named under Deploy & ops. - 2026-09-12 (measured at fd8558f):
test_magic_link_after_verification_returns_tokenfails for a harness reason (Tests). A verifier who sees1 failedin that file should read the assertion before opening a bug. - 2026-09-12 (measured at fd8558f): the
Hostfallback in_requested_surfaceonly fires when the API is served under a surface's own hostname; the two Prima clients sendsurfaceexplicitly for that reason, and the Labs SPA sends nothing and relies on thelabsdefault. A new service serving one API host for several surfaces should require the field rather than infer it. - 2026-09-05 (c591c90, measured at fd8558f): the desk domain sign-in branch sits inside this route (1030 to 1090) and adds
login_email,next_pathandlogin_workspace_idto the row. The 202 is the same across all three resolution shapes on purpose (comment at 1035 to 1038): a distinct answer for "that domain is not ours" would tell an attacker which practices are on the desk. - 2026-08-03 (ceee5ac, measured at fd8558f): before
send_magic_link_email_taskthe route queued the send withdb=Noneand noemail_templatesrow could ever apply to a magic link; the branding fix for Prima (ADR-0023) was invisible until the task opened its own session. A background task that needs the database opens its own session; the request's is gone. - 2026-08-02 (fb7159f, measured at fd8558f): the breach this recipe's invariants exist for.
create_magic_linkfiltered on email andis_activeonly, and anapi_onlyaccount, created active and verified, could request a link and be handed a full Labs session and a workspace. The grant check at mint (1066 to 1073) andsurfaceon the row are the fix;test_magic_link_for_labs_is_refused_for_api_onlyguards it.
Reference implementation
Paths are relative to each repo's root; commits as in measured_on and also_measured_on.
alchemy_labs(branchd1a-token-sourceat fd8558f, backend identical tofeat/service-deskb1089b6):backend/api/routers/auth_router.py:_set_auth_cookies(59 to 80),_verification_required_response(88 to 115),_maybe_resend_verification(117 to 138),_requested_surface(155 to 173),_ensure_workspace(176 to 232),_magic_link_email_throttle(1000 to 1004),request_magic_link(1007 to 1124),verify_magic_link(1126 to 1273); the mount atbackend/app_fastapi.py820.backend/services/auth_service.py:Redemption(307 to 325),SurfaceAccessDenied(332 to 337),create_session(683 to 769),create_magic_link(1046 to 1086),create_magic_link_for_user(1088 to 1145),verify_magic_link(1147 to 1217).backend/models/user_model.py:MagicLink(213 to 242).backend/schemas/auth_schemas.py:MagicLinkRequestwithvalidate_next(138 to 157),MagicLinkVerify(159 to 160).backend/services/email_service.py:selected_transport(48 to 61),transport_is_configured(64 to 76),magic_link_template_names(447 to 479),_resolve_magic_link_template(481 to 491),send_magic_link_email(493 to 577),send_magic_link_email_task(579 to 608),_send_email(855 to 882).backend/api/dependencies/rate_limit.py: the two ceilings (20 to 35),auth_rate_limit(38 to 60),enforce_email_rate_limit(63 to 92);backend/utils/net.py::get_client_ip(29 onward).backend/core/surfaces.py:Surface.originandhosts(42 to 49),SURFACES(125 to 166),ISSUABLE_SURFACES(169),assert_issuable(211 to 220),surface_for_host(238 to 248),link_base_for(327 to 339, uncalled).backend/scripts/issue_magic_links.py: the four silent failures (28 to 51),report_transport(78 onward),issue_for(106 to 162),mainand the argument parser (165 onward).backend/migrations/versions/baseline_prod_schema_001.py(1203 to 1213, 2526 to 2529, 3821 to 3823),20260802_0002_surface_grants.py(70 to 80),20260905_0001_desk_domain_login.py(55 to 70).backend/services/service_desk/signin.py(B20's half:resolve_login_email,create_intent,redeem_intent,record_login).- Tests as listed under Tests.
frontend/src/pages/LoginAlchemy.tsx(151 to 165: the request and its one error string),frontend/src/pages/MagicLinkCallback.tsx(48 to 81: the redemption, the twolocalStoragewrites at 63 to 64),frontend/src/lib/deskLogin.ts::signInDestination(thenextanddesk_onlyrule).
prima-chat(w5-billing-panel, 743b840):server/identity.tsrequestMagicLink(188 to 240),verifyMagicLink(249 to 262).prima-developer(w6-konto, d5f9aa8):src/lib/auth.ts(the contract comment 30 to 52, the two posts 248 to 258 and 330 to 340).alchemycloud(main, b3d34da):scripts/distribute-env.shlabs-prod heredoc (215 to 361) and labs-staging (362 to 551), key names only.- Wiki
philosophers_stone(6322fa3):platform/architecture.md("Labs magic-link silent lockout", the auth table row),operations/decisions/0020-prima-identity-and-prepaid-credits.md(section 1, prerequisite bugfix 1),operations/decisions/0023-transactional-email-must-move-to-infomaniak.md,operations/prima-modules-inventory.md(row B5).
Deviations of the reference from this recipe, all dated 2026-09-12: the link base ignores link_base_for (staging links point at production); --repair-grants grants the wrong set; the Labs-shaped cookie helper on a surface-scoped route; tokens stored verbatim and never purged; the success path logged at WARNING; one harness-red test; the dependency-tier Retry-After dropped by the envelope handler; the Labs SPA storing the verification_required 200 as a session.
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. Expected observations are exact.
python -m pytest tests/test_surface_scoped_access.py -q --no-cov -o addopts="" -W ignoreprints44 passed(rerun once if the first run showserrors);python -m pytest tests/test_login_verification.py -q --no-cov -o addopts="" -W ignore -k "magic_link_unverified"prints1 passed;python -m pytest tests/integration/test_smtp_capture.py tests/unit/test_email_transport.py -q --no-cov -o addopts="" -W ignore -k magicprints17 passed.- With the app running and no transport configured:
curl -s -i -X POST http://127.0.0.1:8000/api/auth/request-magic-link -H 'Content-Type: application/json' -d '{"email":"nobody@example.com"}'shows202and"If the email exists, a magic link has been sent"; the same with"surface":"nope"shows400andUnknown surface 'nope'; the same with"next":"https://evil.example"shows422. - Eleven requests within a minute for the same address from one client show
202ten times and then429with the body{"error":"Too many requests for this email. Please try again later.","code":"RATE_001",...}and noretry-afteramong the response headers (measured 2026-09-12 at fd8558f withTestClient(app_fastapi.app): the eleventh answer carriescontent-length,content-security-policy,content-type,permissions-policy,referrer-policy,strict-transport-security,x-content-type-options,x-frame-options,x-process-time,x-request-id,x-response-time,x-xss-protectionand nothing else); thirty-one requests for distinct addresses from one IP show429on the thirty-first. On a service built to this recipe the same two answers carryRetry-After(Gotchas). - For a user holding
prima_chatand notlabs:python -c "from services.auth_service import auth_service as a; print(a.create_magic_link('<addr>', surface='labs'))"printsNoneand the log showsRefusing magic link ... no grant; the same withsurface='prima_chat'prints a 43-character token;SELECT surface, used FROM magic_links WHERE token = '<t>'showsprima_chat | f. curl -s -X POST http://127.0.0.1:8000/api/auth/verify-magic-link -H 'Content-Type: application/json' -d '{"token":"<t>"}'returns200with"surface":"prima_chat"and a decodedaudofprima_chat; the same request again returns400"Invalid or expired magic link";SELECT used, used_at IS NOT NULL FROM magic_links WHERE token = '<t>'showst | t.- Delete the user's
prima_chatgrant row after minting a fresh token and before redeeming: the redemption is400, and the log showsredeemed after grant revoked. - With
REQUIRE_EMAIL_VERIFICATION=true,ENABLE_EMAIL_VERIFICATION=trueand a user withis_verified = falseholdinglabs: redemption answers200with"status":"verification_required", noaccess_tokenkey and noset-cookieheader. python scripts/issue_magic_links.py --check --email <addr> --surface prima_chatprints one[ ok ]or[ FAIL ]line per address and a transport line, and inserts no row (SELECT count(*) FROM magic_linksunchanged); without--checkit prints a URL of the shapehttps://<surface origin>/auth/magic-link?token=<t>and inserts exactly one row.grep -c "surface" backend/schemas/auth_schemas.pycounts onlyMagicLinkRequest's field and comments, none insideMagicLinkVerify(lines 159 to 160 declaretokenonly);grep -c "get_user_surfaces" backend/services/auth_service.pyprints at least4(mint, named mint, redemption, andcreate_session).
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.