AUTH4D-212 change or remove an account’s email address

A signed-in player who has a verified email address (added with AUTH4D-149 (repository file docs/api-spec/auth4d-149.md), or from email sign-in) can change it or remove it. Both reuse the AUTH4D-149 add-email intent: the same handle, poll secret, hosted page, per-intent lock, 600-second expiry, no-store JSON and CORS rules. Only the differences are described here.

Create an intent (application)

POST /t/{tenantId}/account/email/intent
Authorization: Bearer {access token}
Content-Type: application/json

{ "client_id": "cli_…", "action": "change" }

action is add, change or remove (AddEmailIntentActionSchema in packages/contracts). It is optional; without it the request means add, exactly as in AUTH4D-149. The response shape is unchanged. add_email_uri points at the hosted page for the action: /login/add-email, /login/change-email or /login/remove-email, each with tenant_id, client_id and intent.

The intent records which email identity it changes or removes. Creation errors, in addition to those of AUTH4D-149:

Status error When
400 invalid_request Unknown action
409 email_not_added change or remove for an account without an email identity
409 only_sign_in_method remove while the address is the only way to sign in (see below)
409 email_sign_in_disabled add or change while email sign-in is off (a removal sends no email)
409 guest_account The user is still a guest (unchanged; a guest has no address to change)

Intents of every action share the AUTH4D-149 budget of 10 per user per hour.

Other sign-in methods. An address can be removed only while the account keeps another way to sign in: another identity that is not a guest identity (Steam, Discord, Epic Games, Google, Apple, Microsoft, SMS, single sign-on, or a second email identity) or a passkey. A guest identity does not count, because removing the address would turn the account back into a guest. The check runs when the intent is created and again, inside the delete statement, when the player confirms.

Hosted page steps (browser)

The steps keep the AUTH4D-149 rules: Origin: {AUTH_ORIGIN} only, the bound flow cookie, and no tokens in requests or responses.

  • begin now also returns the intent’s action: { "status": "pending", "action": "change", "expires_in": 580 }. The page follows this action, not its own path.

  • complete (add and change) takes the email proof as before. For a change it also checks that the account still has exactly the email identity the intent recorded, then, in one D1 transaction, moves the new address’s provisional identity to the player, moves a password from the old email identity to the new one (unless the new one already has its own), deletes the old email identity, and disables the emptied provisional user. It revokes the session that created the intent and returns { "status": "approved", "identity": { "identity_id", "provider": "email" } }. Additional errors: 409 email_unchanged (the code proved the account’s current address; the intent stays pending), 409 email_not_added (the recorded address is gone), and 400 invalid_request for a remove intent.

  • remove (new, AddEmailIntentBrowserRequestSchema):

    POST /t/{tenantId}/account/email/intent/remove
    Content-Type: application/json
    
    { "client_id": "cli_…", "intent": "aei_…", "browser_flow_id": "flw_…" }

    Needs no code, only the player’s confirmation. It deletes the recorded email identity in one statement that re-checks the other sign-in methods (a password on that identity is deleted with it by the 0022 trigger), revokes the session that created the intent and returns { "status": "removed" }. Errors: 409 only_sign_in_method and 409 email_not_added (the intent stays pending), 400 invalid_request for an add or change intent, 401 invalid_session (the user was disabled), plus the common step errors of AUTH4D-149 (400 expired_token, 403 access_denied, 409 access_denied, 401 unauthorized_client, 429 slow_down). Removal attempts share the 10-per-user-per-hour completion budget.

  • deny is unchanged.

Poll for the result (application)

Polling is unchanged. For a change, the token set carries the new address in identity and email. For a remove, the response has user_id, the token set and session_id, with no identity or email. The new session has no address. Access tokens carry no auth_time, so its auth_time is the start of the session it replaces.

Audit events

Both are written with actor_id and target_id set to the user and never contain an address:

Event Metadata
user.email_changed client_id, provider, identity_id (new), previous_identity_id
user.email_removed client_id, provider, identity_id (removed)

Hosted pages

/login/change-email and /login/remove-email run the add-email page module.

  • Change: Change your email address with Not now, then the email-code step for the new address, Use {email}? before calling complete, and the changed, can’t-be-used, already-your-address, cancelled, expired, already-finished and opened-in-another-browser outcomes.
  • Remove: Remove your email address? explains that the address and any password set for it stop working, with Remove email address and Not now, then the removed or only-sign-in-method outcomes.

SDKs

The browser SDK has changeEmail() and removeEmail() (see AUTH4D-256 (repository file docs/api-spec/auth4d-256.md)). The Unity SDK doesn’t have change or remove methods yet; Unity games call the endpoints above and open add_email_uri themselves. AddEmailAsync() is unchanged.

Tests

  • tests/integration/auth4d-212.change-remove-email.test.ts: change with a password moved and tokens collected once, taken and unchanged addresses refused, creation refusals and the default add action, removal with its password, only-sign-in-method refusals (email only, email plus guest, a passkey allowed, Steam unlinked after creation), the wrong step for an action, another browser, tenant isolation and the audit events. Run with npx vitest run --config vitest.config.ts --no-file-parallelism --testTimeout=30000 auth4d-212 in tests/integration.
  • tests/e2e/auth4d-212.change-remove-email.spec.ts (port 5312): the hosted pages, including axe checks and 320px layouts.

Source: docs/api-spec/auth4d-212.md