Prima Get an API key

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
  • 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/services/email_service.py
  • alchemy_labs/backend/scripts/issue_magic_links.py
Measured on alchemy_labs d1a-token-source fd8558f 2026-09-12
Also measured on
  • prima-chat w5-billing-panel 743b840 2026-09-12
  • prima-developer w6-konto d5f9aa8 2026-09-12
  • alchemycloud main b3d34da 2026-09-12
  • philosophers_stone main 6322fa3 2026-09-12
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):

NameDefaultBehaviour
EMAIL_TRANSPORTsmtpsmtp 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_KEYemptywhat transport_is_configured() checks when the transport is resend (email_service.py 64 to 76)
EMAIL_SMTP_HOST, EMAIL_SMTP_USERNAME, EMAIL_SMTP_PASSWORDhost default, empty, emptywhat it checks when the transport is smtp
FRONTEND_URLhttp://localhost:3000the link base for labs in development only (auth_router.py 1090 to 1092); every other case uses the surface's canonical origin
ENVIRONMENTdevelopmentin development a minted link is also logged at INFO (1118 to 1120)
REQUIRE_EMAIL_VERIFICATION, ENABLE_EMAIL_VERIFICATIONboth Truewith both on, redeeming a link for an unverified account answers verification_required and no tokens (1175 to 1186)
TRUSTED_PROXY_COUNT1which X-Forwarded-For hop the per-IP bucket keys on (utils/net.py 29 to 41)
MAX_SESSIONS_PER_USER5trimmed 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.

RouteAuth depTenancyCodesError codes and bodies
POST /api/auth/request-magic-linknone; 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)none202 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-linknone, no rate limitnone200 {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:

  1. Dependencies: auth_rate_limit (per IP, bucket rate_limit:auth:/api/auth/request-magic-link:<ip>), then _magic_link_email_throttle, which re-reads the body model and calls enforce_email_rate_limit(email, scope="magic-link") (1000 to 1004). A 429 from either is raised with Retry-After (rate_limit.py 59, 89) and reaches the client without it at fd8558f, because the envelope handler never reads exc.headers (Gotchas, and B18, which owns the measurement); a service built to this recipe forwards the header.
  2. _requested_surface(request, body.surface) (155 to 173): an explicit id wins and must be issuable (else 400); otherwise surface_for_host(Host) (core/surfaces.py 238 to 248, exact hostname match against each surface's hosts); otherwise labs. The answer is a request, not a decision.
  3. Desk domain sign-in, only when the surface is labs (1030 to 1055): desk_signin.resolve_login_email may return a resolution for an address at a proven domain; any exception falls back to None with a rollback. Everything below the None branch is this recipe; the other two branches are B20 and end in create_magic_link_for_user (1075 to 1081) with login_email, next_path and login_workspace_id.
  4. auth_service.create_magic_link(email, surface=surface_id) (auth_service.py 1046 to 1086): assert_issuable(surface); in its own session, load the user by lowercased email; None when missing or inactive; None when surface not in get_user_surfaces(db, user.id) (logged at WARNING Refusing magic link for user <id> on surface <s>: no grant); otherwise insert the row with token = secrets.token_urlsafe(32), surface, expires_at = utcnow + 15 min, commit, return the token.
  5. base_url = surface.origin; in development and only for labs, FRONTEND_URL instead (1090 to 1092).
  6. 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.
  7. Else if a token exists and ENVIRONMENT == "development": log the full URL at INFO (1117 to 1120).
  8. 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:

  1. auth_service.verify_magic_link(token) (auth_service.py 1147 to 1217): in its own session, select the row where token matches, used == False and expires_at > utcnow; None otherwise. surface = row.surface or "labs"; not in ISSUABLE_SURFACES is None with an ERROR log. Load the user; missing or inactive is None. surface not in get_user_surfaces(db, user.id) is None with a WARNING (redeemed after grant revoked). Then mark used = True, used_at, user.last_login, commit, return the Redemption.
  2. A None from step 1 is tried as a desk sign-in intent (desk_signin.redeem_intent, 1151 to 1159); still None is the uniform 400.
  3. Re-select the user in the request's session (db.get(User, red.user_id), 1168); missing or inactive is the same 400.
  4. With both verification flags on and not user.is_verified: best-effort resend of the verification mail and return the verification_required body, no tokens, no cookies (1175 to 1186). Every redemption is logged at WARNING with user, surface and the flag (1180 to 1183).
  5. If red.login_email differs from users.email, record the login binding (B20, 1191 to 1204); failures are logged and never cost the login.
  6. Ask desk_access.is_desk_only_user (1210 to 1216); DeskAccessUnavailable is 503, because _ensure_workspace swallows 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.
  7. create_session(user_id, ip, ua, surface=red.surface, acting_email=<login_email when it differs>) (1225 to 1237); SurfaceAccessDenied is 403.
  8. Cookies, then the 200 body with surface, acting_email, next, desk_only and user.

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 (MagicLinkVerify has token only; test_magic_link_surface_comes_from_the_token_row_not_the_request, tests/test_surface_scoped_access.py 501).
  • The grant is checked twice, at mint (create_magic_link 1066 to 1073) and at redemption (verify_magic_link 1194 to 1201), and a third time inside create_session; revoking a grant between the mail and the click is a 400, not a session.
  • POST /request-magic-link answers 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 (used flipped inside the same transaction that returns the Redemption) and expires 15 minutes after mint (auth_service.py 1081, 1141); there is no refresh of a link.
  • A next is a relative path or nothing (validate_next, auth_schemas.py 149 to 156); an absolute URL or //host never reaches the row, so the login mail is not an open redirect.
  • login_email is stored only when it differs from users.email (create_magic_link_for_user 1131 to 1133) and becomes aem on 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_link 1070 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 at FRONTEND_URL (test_prima_magic_link_on_the_wire, tests/integration/test_smtp_capture.py 252).
  • The transport gate is transport_is_configured(), never the presence of one vendor's key (auth_router.py 1094 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 for prima_chat, redeem, mint a session, decode with expected_surface="prima_chat" and read aud), 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 answer 202 with equal bodies). A first run of the file printed 11 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 through verify_email, which commits in its own get_db_context() session (auth_service.py 920 to 949), then redeems through the test client, whose request session is the fixture's transactional session (tests/conftest.py 146 to 167) and still holds the pre-verification User in its identity map, so the router reads is_verified == False and answers verification_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 among 4 passed for -k magic_link.
  • tests/unit/test_email_transport.py (-k magic): 15 passed, including test_magic_link_goes_through_the_selected_transport (269). tests/integration/test_smtp_capture.py: 2 passed, test_prima_magic_link_on_the_wire (252) and test_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) pins link_base_for, which the route does not call (Gotchas).
  • test_critical_endpoints.py::TestMagicLinkEndpoints (353 to 396): 14 failed, 33 passed for the file at fd8558f, every failure an UndefinedTable from the harness (relation "magic_links" does not exist in 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_required 200 as a successful sign-in. verify_magic_link answers that body with 200, no tokens and no cookies (auth_router.py 1175 to 1186), and MagicLinkCallback.tsx tests response.ok only (56), then writes data.access_token and data.refresh_token into localStorage (63 to 64), calls signInDestination(data) (69) and shows the success animation (81); grep -rn verification_required frontend/src prints nothing. An unverified Labs account therefore stores the string undefined as its access token, is navigated to /dashboard, and sees a failed /me instead of "confirm your address". Fix: branch on data.status === "verification_required" before the two localStorage writes and render the resend state. The request screen is unbranched in the same way: LoginAlchemy.tsx 159 to 160 turns every non-ok response into Fehler beim Senden des Login-Links and reads neither response.status nor Retry-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-After reaches the client. auth_rate_limit and enforce_email_rate_limit raise 429 with the header (rate_limit.py 59 and 89), and http_exception_handler (app_fastapi.py 724 to 788) builds both of its answers (return JSONResponse( at 754 and 779) with headers=_error_cors_headers(request) (765 and 787) and never reads exc.headers. Measured with TestClient(app_fastapi.app): ten POST /api/auth/request-magic-link for 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 no retry-after among its headers. identity-auth/rate-limiting (B18) owns the measurement across all four tiers; only the middleware tier's raw JSONResponse carries 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 labs outside development is the canonical origin surface.origin (auth_router.py 1090 to 1092), not FRONTEND_URL; core/surfaces.py::link_base_for (327 to 339), which would use FRONTEND_URL for Labs, has zero callers outside tests (grep -rn link_base_for backend --include='*.py' hits its definition, its docstring mention and test_webauthn_multi_rp.py 847). 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: call link_base_for(surface) at line 1090 and delete the development special case; the test at 847 already pins the function.
  • 2026-09-12 (measured at fd8558f): scripts/issue_magic_links.py --repair-grants grants API_ONLY_SURFACES (the three Prima surfaces) whatever --surface was asked for (lines 127 to 131) and does not re-check the requested surface afterwards, so --surface labs --repair-grants on an account without the labs grant prints granted [...] and then create_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 repair labs, 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 /login set cookies through auth_router._set_auth_cookies (59 to 80: no Domain, SameSite=Lax), the passkey routes through core/session_cookies.py::set_auth_cookies (64 to 113: the surface's cookie_domain when same-site, SameSite=None; Secure when cross-site). A prima_chat magic-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: call set_auth_cookies(response, a, r, surface=get_surface(surface_id), request=request) at line 1240.
  • 2026-09-12 (measured at fd8558f): verify_magic_link logs 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_links and password_resets rows are never deleted; used and expires_at are 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_token fails for a harness reason (Tests). A verifier who sees 1 failed in that file should read the assertion before opening a bug.
  • 2026-09-12 (measured at fd8558f): the Host fallback in _requested_surface only fires when the API is served under a surface's own hostname; the two Prima clients send surface explicitly for that reason, and the Labs SPA sends nothing and relies on the labs default. 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_path and login_workspace_id to 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_task the route queued the send with db=None and no email_templates row 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_link filtered on email and is_active only, and an api_only account, 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) and surface on the row are the fix; test_magic_link_for_labs_is_refused_for_api_only guards it.

Reference implementation

Paths are relative to each repo's root; commits as in measured_on and also_measured_on.

  • alchemy_labs (branch d1a-token-source at fd8558f, backend identical to feat/service-desk b1089b6):
    • 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 at backend/app_fastapi.py 820.
    • 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: MagicLinkRequest with validate_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.origin and hosts (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), main and 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 two localStorage writes at 63 to 64), frontend/src/lib/deskLogin.ts::signInDestination (the next and desk_only rule).
  • prima-chat (w5-billing-panel, 743b840): server/identity.ts requestMagicLink (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.sh labs-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.

  1. python -m pytest tests/test_surface_scoped_access.py -q --no-cov -o addopts="" -W ignore prints 44 passed (rerun once if the first run shows errors); python -m pytest tests/test_login_verification.py -q --no-cov -o addopts="" -W ignore -k "magic_link_unverified" prints 1 passed; python -m pytest tests/integration/test_smtp_capture.py tests/unit/test_email_transport.py -q --no-cov -o addopts="" -W ignore -k magic prints 17 passed.
  2. 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":"[email protected]"}' shows 202 and "If the email exists, a magic link has been sent"; the same with "surface":"nope" shows 400 and Unknown surface 'nope'; the same with "next":"https://evil.example" shows 422.
  3. Eleven requests within a minute for the same address from one client show 202 ten times and then 429 with the body {"error":"Too many requests for this email. Please try again later.","code":"RATE_001",...} and no retry-after among the response headers (measured 2026-09-12 at fd8558f with TestClient(app_fastapi.app): the eleventh answer carries content-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-protection and nothing else); thirty-one requests for distinct addresses from one IP show 429 on the thirty-first. On a service built to this recipe the same two answers carry Retry-After (Gotchas).
  4. For a user holding prima_chat and not labs: python -c "from services.auth_service import auth_service as a; print(a.create_magic_link('<addr>', surface='labs'))" prints None and the log shows Refusing magic link ... no grant; the same with surface='prima_chat' prints a 43-character token; SELECT surface, used FROM magic_links WHERE token = '<t>' shows prima_chat | f.
  5. curl -s -X POST http://127.0.0.1:8000/api/auth/verify-magic-link -H 'Content-Type: application/json' -d '{"token":"<t>"}' returns 200 with "surface":"prima_chat" and a decoded aud of prima_chat; the same request again returns 400 "Invalid or expired magic link"; SELECT used, used_at IS NOT NULL FROM magic_links WHERE token = '<t>' shows t | t.
  6. Delete the user's prima_chat grant row after minting a fresh token and before redeeming: the redemption is 400, and the log shows redeemed after grant revoked.
  7. With REQUIRE_EMAIL_VERIFICATION=true, ENABLE_EMAIL_VERIFICATION=true and a user with is_verified = false holding labs: redemption answers 200 with "status":"verification_required", no access_token key and no set-cookie header.
  8. python scripts/issue_magic_links.py --check --email <addr> --surface prima_chat prints one [ ok ] or [ FAIL ] line per address and a transport line, and inserts no row (SELECT count(*) FROM magic_links unchanged); without --check it prints a URL of the shape https://<surface origin>/auth/magic-link?token=<t> and inserts exactly one row.
  9. grep -c "surface" backend/schemas/auth_schemas.py counts only MagicLinkRequest's field and comments, none inside MagicLinkVerify (lines 159 to 160 declare token only); grep -c "get_user_surfaces" backend/services/auth_service.py prints at least 4 (mint, named mint, redemption, and create_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.