AUTH4D-142 Email magic links
A browser application can offer an emailed sign-in link next to the six-digit email code on the
hosted login page. The link is a single-use, ten-minute challenge in the tenant’s challenge Durable
Object, bound to the browser flow that asked for it through the same __Host-auth4_flow_{flowId}
cookie as email codes. Opening the link in any other browser cannot complete, or use up, that sign-in.
A successful link gives the same email_verification identity proof as a correct code.
Start
POST /t/{tenantId}/email/magic-link/start takes the same JSON body as
/t/{tenantId}/email/verification/start:
{ "client_id": "cli_…", "browser_flow_id": "flw_…", "email": "player@example.com" }
The request must come from the auth origin (Origin), carry the flow cookie, and name a live OAuth
authorization flow of this tenant and client whose cookie hash matches. The client must be an
enabled browser application in an active customer project, email sign-in must be on for the
project, and the application must have magic links on. If any check fails the answer is
400 invalid_request, or 403 access_denied for a missing or unmatched flow. Native applications
never offer links: games sign in with device authorization, and guest-linking flows keep email codes.
The send limits, the response and the enumeration resistance match email codes. Codes and links for
the same flow and address share one 60-second resend cooldown, and the per-address, per-IP,
per-project and global send budgets are the same buckets. The answer is always
202 {"expires_in": 600, "resend_after": 60}, whether the email was sent, throttled, or the address
has no account. Sending a new link invalidates the earlier link for that flow and address. A delivery
failure is 503 temporarily_unavailable.
The email goes through the encrypted outbox (template magic-link) with the application’s name and
support URL, like a code email. The link is
{AUTH_ORIGIN}/login/magic#tenant_id={tenantId}&challenge_id={challengeId}. The 256-bit challenge
id is the only secret, and it is only in the URL fragment, so browsers never send it to a server or
in a Referer. It is never stored in an outbox column or the queue, only in the encrypted payload.
The challenge record is keyed magic-link:{challengeId} under the tenant. It holds the tenant,
client, flow, the hash of the flow cookie, the normalized address (the verify request does not carry
it) and the expiry. It never reaches D1 and is deleted when it is used or expires.
Landing page
GET /login/magic serves the hosted-login shell with Referrer-Policy: no-referrer and
Cache-Control: no-store. The page reads the fragment, removes it from the address bar and history,
and then calls verify. Link scanners that fetch the page without running it change nothing.
Verify
POST /t/{tenantId}/email/magic-link/verify with {"challenge_id": "…"} (43 base64url
characters), from the auth origin. Each call uses the per-IP attempt budget shared with email-code
guesses (429 slow_down when it is spent). The challenge is taken atomically, then:
| Case | Response |
|---|---|
| Unknown, already used, replaced by a newer link, expired, or under another tenant | 400 {"error": "invalid_link"} |
| No flow cookie for the link’s flow, or a cookie whose hash does not match | 409 {"error": "different_device"}; the challenge is restored |
| The application turned links or email sign-in off, or sign-up is off for a new address | 400 {"error": "invalid_link"} |
| The browser flow has expired or was already used | 403 {"error": "access_denied"} |
| Success | 200, below |
{
"client_id": "cli_…",
"browser_flow_id": "flw_…",
"proof": { "proof_id": "prf_…", "expires_in": 300 }
}
Because a different_device answer puts the challenge back unchanged, the player can still open the
link in the original browser. The page then shows a “continue on the original device” screen and
never displays a server-provided description. On success the address resolves to the tenant’s
email identity, or creates a user when sign-up is on, exactly as a correct code does. The page
passes the proof to POST /oauth/authorize/continue with proof_purpose: "email_verification".
Every JSON answer is Cache-Control: no-store.
Login context
GET /t/{tenantId}/login/context adds "magic_link": true to login_methods only when the
application is a browser application with magic links on and email sign-in is on. Otherwise the key
is absent and the response is unchanged.
Management API
Magic links are a per-application setting for browser applications:
GET /management/tenants/{tenantId}/clients/{clientId}addsmagic_link:{"enabled": bool}for browser applications andnullfor game applications.PATCH /management/tenants/{tenantId}/clients/{clientId}accepts{"magic_link": {"enabled": true|false}}(client:write, so owners and admins). For a game application it answers400 magic_link_unsupported. A change writes amanagement.client.updatedaudit event with metadatamagic_link: "enabled"|"disabled". Replaying the same value writes nothing.GET /management/tenants/{tenantId}/clientsaddsmagic_link_client_ids, the browser applications that have links on, so the console’s sign-in methods screen needs one request.
The setting is stored in clients.magic_link_enabled (migration 0016, default 0). Code that runs
before the migration reads a missing column as off.
Source: docs/api-spec/auth4d-142.md