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} adds magic_link: {"enabled": bool} for browser applications and null for 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 answers 400 magic_link_unsupported. A change writes a management.client.updated audit event with metadata magic_link: "enabled"|"disabled". Replaying the same value writes nothing.
  • GET /management/tenants/{tenantId}/clients adds magic_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