AUTH4D-149 add an email address to a signed-in account
A player who signed up with Steam (or Discord) has no email identity. A signed-in player can add
one, verified with an email code, and it becomes an email identity of the same user. The flow
is an add-email intent, built like the native guest upgrade intent (AUTH4D-67 (repository file docs/api-spec/auth4d-67.md))
and used by browser and native clients alike:
- The application creates an intent with the player’s access token. It receives a non-secret intent handle, a poll secret and the hosted page URL.
- The application opens
add_email_uri(/login/add-email?…&intent={handle}): a window in the browser SDK, the system browser in Unity. - The hosted page binds the intent to that browser, runs the existing email-code step
(
/t/{tenantId}/email/verification/startand/confirm) and, after the player confirms, adds the proven address. - The application polls with the poll secret. It gets
authorization_pendinguntil the page finishes, then a refreshed token set for the same user, exactly once.
The handle appears in a URL and is not a secret. On its own it can only bind the intent to the
first browser that opens it. Only the poll secret, which never leaves the application, collects
tokens. The API stores SHA-256 hashes of both. Intents live for ADD_EMAIL_INTENT_EXPIRY_SECONDS
(600 seconds), are keyed by tenant, and every state change runs under a per-intent lock in the
tenant’s Durable Object. All responses are JSON with Cache-Control: no-store.
Since AUTH4D-343 (repository file docs/api-spec/auth4d-343.md), binding also needs the one-time browser verifier from the page
URI’s fragment, so the handle alone binds nothing; begin also labels the account, and creating
a credential-changing intent needs a recent sign-in.
Conflicts are refused without revealing them. Sending and confirming the code behave the same
for a new address and for one that already belongs to another user of the tenant (the
enumeration-resistant AUTH4D-13 step). Only after the code has proved the player controls the
mailbox does complete return 409 email_unavailable, which does not say why. Accounts are never
merged. The intent stays pending, so the player can try a different address.
Create an intent (application)
POST /t/{tenantId}/account/email/intent
Authorization: Bearer {access token}
Content-Type: application/json
{ "client_id": "cli_…" }
The bearer must be a live access token for this tenant and client (audience client_id). The user
must not be disabled, must not be a guest (a guest identity and nothing else; guests use the guest
upgrade), and must not already have an email identity. Email sign-in must be enabled for the
tenant. Intents are budgeted at 10 per user per hour.
{
"intent": "aei_…",
"poll_secret": "aep_…",
"add_email_uri": "https://auth.auth4.dev/login/add-email?tenant_id=ten_…&client_id=cli_…&intent=aei_…",
"expires_in": 600,
"interval": 5
}
| Status | error |
When |
|---|---|---|
| 400 | invalid_request |
Malformed body |
| 401 | unauthorized_client |
Unknown, disabled or server client, or inactive tenant |
| 401 | invalid_session |
Missing, expired or revoked token, or a disabled user |
| 403 | access_denied |
Browser Origin not registered on this client |
| 409 | guest_account |
The user is still a guest; use the guest upgrade |
| 409 | email_already_added |
The user already has an email identity |
| 409 | email_sign_in_disabled |
Email sign-in is turned off for the tenant |
| 429 | slow_down |
Intent budget exhausted |
| 503 | temporarily_unavailable |
Session or atomic state unavailable |
Wire shapes: AddEmailIntentCreateRequestSchema and AddEmailIntentCreateResponseSchema in
packages/contracts. The handle matches ^aei_[A-Za-z0-9_-]{32,64}$ and the poll secret matches
^aep_[A-Za-z0-9_-]{43,86}$.
Poll for the result (application)
POST /t/{tenantId}/account/email/intent/token
Content-Type: application/json
{ "client_id": "cli_…", "intent": "aei_…", "poll_secret": "aep_…" }
Polling semantics match AUTH4D-67 (repository file docs/api-spec/auth4d-67.md): the first poll is due interval seconds after
creation, and polling early returns slow_down and adds 5 seconds (up to 60).
| Status | Body | Meaning |
|---|---|---|
| 400 | { "error": "authorization_pending", "interval" } |
The player has not finished yet |
| 400 | { "error": "slow_down", "interval" } |
Polled faster than the interval, or another poll is in progress |
| 400 | { "error": "access_denied" } |
The player chose Not now; reported once |
| 400 | { "error": "expired_token" } |
The intent expired, or there is no such intent in this tenant |
| 400 | { "error": "invalid_grant" } |
Wrong poll secret, or the result was already collected (replay) |
| 401 | { "error": "invalid_client" } |
Unknown, disabled or server client, or inactive tenant |
| 403 | { "error": "access_denied" } |
Browser Origin not registered on this client |
| 429 | { "error": "slow_down" } |
Poll budget exhausted (12 per intent and 60 per IP each minute) |
| 200 | Refreshed token set | Returned exactly once |
{
"user_id": "usr_…",
"identity": { "identity_id": "idn_…", "provider": "email" },
"email": "player@example.com",
"access_token": "…",
"token_type": "Bearer",
"expires_in": 300,
"refresh_token": "rfr_…",
"id_token": "…",
"scope": "openid offline_access",
"session_id": "ses_…"
}
The refreshed session has the same user, client and scopes as the session that created the intent,
and carries the verified address (email, email_verified: true). refresh_token is present only
when the scopes include offline_access. The session that created the intent was revoked when the
address was added.
CORS for browser applications
The browser SDK calls create and poll from the application’s page. As for guest sign-in
(AUTH4D-122 (repository file docs/api-spec/auth4d-122.md)), deployment-wide origins keep the boundary policy, and any other
Origin must be registered on the named browser client. Preflights are answered for an origin
registered on any enabled browser client of the tenant in the path: POST with authorization and
content-type for create, and content-type only for poll. Responses never allow credentials.
Hosted page steps (browser)
Called by /login/add-email only. They require Origin: {AUTH_ORIGIN} (otherwise
403 access_denied) and never accept or return tokens.
POST /t/{tenantId}/account/email/intent/begin
POST /t/{tenantId}/account/email/intent/complete
POST /t/{tenantId}/account/email/intent/deny
Content-Type: application/json
{ "client_id": "cli_…", "intent": "aei_…", "browser_flow_id": "flw_…" }
complete also takes "proof_id": "prf_…" (AddEmailIntentCompleteRequestSchema); the other two use
AddEmailIntentBrowserRequestSchema.
- begin binds a pending intent to this browser. It creates a linking flow that points at the
hosted login (so native clients can run the email step on it too), sets
__Host-auth4_flow_{browser_flow_id}(HttpOnly; Secure; SameSite=Lax) and returns{ "status": "pending", "expires_in" }. Repeating it from the same browser returns the same result; any other browser gets403 access_denied. - complete requires the bound flow cookie and an email proof issued after binding for the same
flow. If the address is new to the tenant, the provisional user created by the code step is
emptied and disabled and its email identity moves to the player, in one D1 transaction that also
checks the player still has no email identity. It then revokes the session that created the
intent, writes a
user.email_addedaudit event (actor_idandtarget_idare the user; the metadata holdsclient_id,providerandidentity_id, never the address) and returns{ "status": "approved", "identity": { "identity_id", "provider": "email" } }. Errors:409 email_unavailable(the address belongs to another user; the intent stays pending),409 email_already_added,400 access_denied(missing, expired or foreign proof; the intent stays pending),401 invalid_session(the user was disabled). - deny requires the bound flow cookie, ends the intent and returns
{ "status": "denied" }.
Errors common to the three steps: 400 expired_token, 403 access_denied (wrong origin, or
another browser), 409 access_denied (already completed, denied or collected),
401 unauthorized_client and 429 slow_down (step in progress, or 10 completion attempts per user
per hour exhausted).
Hosted page
/login/add-email?tenant_id={tenantId}&client_id={clientId}&intent={handle}. A malformed link
shows the invalid-link page. The page binds the intent and shows Add an email address with
Not now, runs the email-code step, asks Add {email}? before calling complete, and shows
the added, can’t-be-added, cancelled, expired, already-finished and opened-in-another-browser
outcomes. It keeps only the non-secret flow ID (keyed by handle) in sessionStorage.
SDKs
- Browser:
Auth4BrowserClient.addEmail()opens the window inside the click, creates the intent, polls, replaces the in-memory token set and emits anemail_addedsession event. It rejects withguest_account,email_already_added,email_sign_in_disabled,cancelled,window_closed,popup_blocked,invalid_sessionoradd_email_failed. - Unity:
Auth4Client.AddEmailAsync()opens the page with theBrowserLauncher, polls, and commits the refreshed session. Failures throwAuth4AddEmailExceptionwith anAddEmailFailurereason.
Tests
tests/integration/auth4d-149.add-email.test.ts: a Steam-only player adds an address (identity on the same user, provisional user disabled, refreshed tokens once, audit event without the address), a taken address refused withemail_unavailableand a retry, creation refusals, first- browser binding, poll secret and Not now, expiry and tenant isolation, and browser CORS. Run withpnpm --filter @auth4/integration exec vitest run --config vitest.config.ts auth4d-149.packages/sdk-browser/src/add-email.test.tsandpackages/sdk-unity/Tests~/Auth4.Protocol.Tests/AddEmailTests.cs: the SDK sides.tests/e2e/auth4d-149.add-email.spec.ts(port 5249): the hosted page.
Source: docs/api-spec/auth4d-149.md