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
changeit 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), and400 invalid_requestfor aremoveintent. -
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_methodand409 email_not_added(the intent stays pending),400 invalid_requestfor anaddorchangeintent,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 withnpx vitest run --config vitest.config.ts --no-file-parallelism --testTimeout=30000 auth4d-212intests/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