AUTH4D-343 account-change intents: browser verifier, account label, recent sign-in and notices

Three intents change how a player signs in: the native guest upgrade (AUTH4D-67 (repository file docs/api-spec/auth4d-67.md)), adding, changing or removing an email address (AUTH4D-149 (repository file docs/api-spec/auth4d-149.md), AUTH4D-212 (repository file docs/api-spec/auth4d-212.md)) and linking or unlinking Steam, Discord or Epic Games (AUTH4D-308 (repository file docs/api-spec/auth4d-308.md)). Until AUTH4D-343 the first browser to open an intent’s handle could bind it. Anyone who saw a handle, or was sent someone else’s link, could then attach a newly verified address or provider account to an account that was not theirs. This change makes four additive changes to all three intents. Request and response fields only gain optional members.

  1. Browser verifier. Every intent has a one-time verifier, and only the begin that presents it binds the intent to its browser. The handle alone (from a log, a URL bar or a forwarded request) binds nothing.
  2. Account label. Once bound, begin returns whose account the intent changes, and the hosted page shows it before the player confirms anything.
  3. Recent sign-in. Creating an add, change or remove email intent, or a link or unlink intent, needs a sign-in from the last ACCOUNT_CHANGE_MAX_AUTH_AGE_SECONDS (300) seconds.
  4. Notices and sign-out. A change, removal, link or unlink sends a notice to the account’s verified address. A change, removal or unlink also ends every session of the account.

Browser verifier

POST /t/{tenantId}/guest/upgrade-intent, /account/email/intent and /account/link/intent accept an optional browser_challenge (IntentBrowserChallengeSchema, 43 base64url characters):

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

Default: the API makes the verifier. Without browser_challenge, the API makes a random verifier, stores only its S256 hash with the intent, and returns the page URI with the verifier in its fragment:

https://auth.auth4.dev/login/change-email?tenant_id=ten_…&client_id=cli_…&intent=aei_…#verifier=Vx…

A browser never sends a fragment to a server or puts it in a Referer. The app opens the URI exactly as returned. If it adds a query parameter (the guest upgrade’s provider hint), it must put the parameter before the #. The Unity, Godot and Unreal SDKs do this.

Optional: the app makes the verifier. An app that would rather never receive the verifier makes one itself (43 to 128 characters from A–Z a–z 0–9 - . _ ~, as an RFC 7636 code verifier) and sends browser_challenge = BASE64URL(SHA-256(verifier)). The returned URI then has no fragment, and the app appends #verifier={its verifier} before opening it.

The hosted page reads the verifier from the fragment and sends it in begin (IntentBrowserVerifierSchema):

POST /t/{tenantId}/account/email/intent/begin
Origin: https://auth.auth4.dev
Content-Type: application/json

{ "client_id": "cli_…", "intent": "aei_…", "browser_flow_id": "flw_…", "browser_verifier": "Vx…" }
Intent state begin without a matching verifier begin with the matching verifier
not yet bound 403 access_denied, nothing bound 200, binds this browser (sets the flow cookie)
bound to this browser 200 (a reload needs only the cookie) 200
bound to another browser 403 access_denied 403 access_denied

complete, remove, unlink and deny are unchanged. They still need the bound browser’s __Host-auth4_flow_{flow} cookie, which only a browser that presented the verifier can hold. So a forwarded handle can neither begin nor complete an intent. An intent created before this change has no stored challenge and can no longer be bound; intents expire after 10 minutes anyway.

The verifier is compared in constant time against its stored hash. It is never logged, never stored by the hosted page (the fragment stays in the page address so a reload works) and never sent to any other endpoint.

Account label

A successful begin now also returns account (IntentAccountLabelSchema):

{
  "status": "pending",
  "action": "change",
  "expires_in": 571,
  "account": {
    "masked_email": "p***@e***.com",
    "methods": ["email", "steam"],
    "guest": false,
    "created_at": "2026-03-14T09:30:00.000Z"
  }
}
  • masked_email: the account’s first verified address, masked as in the verified email step. It is absent when the account has none (a Steam-only player, a guest).
  • methods: the kinds of sign-in method, never subjects or addresses.
  • guest: whether the account is still a guest.
  • created_at: when the account was created.

The label is only returned once the browser has proved the verifier (or holds the binding cookie), so a handle alone reveals nothing. The hosted pages show it under Account this changes on the first screen (and on the final confirmation where the page still knows it), with “If this is not your account, choose Not now.” The link intent’s begin response (LinkAccountIntentBeginResponseSchema) gains the same optional account field. The guest upgrade’s begin response gains it too.

What the verifier does not stop. An attacker can create an intent on their own account and send its full URI, fragment included, to someone else. The verifier cannot tell that browser apart from the attacker’s own. Against that, the page shows whose account is about to change, and attaching an address or provider account still needs the player’s explicit confirmation. A provider account or address that already belongs to someone is still never moved (identity_in_use, email_unavailable), so a player who already has an account cannot lose it this way.

Recent sign-in

Creating an email intent (any action) or a link intent (either action) now checks the auth_time of the session behind the access token. Access tokens carry no auth_time. It is read from the session’s Durable Object record, which keeps it across refreshes. If it is more than 300 seconds old, the request is refused before anything is stored:

HTTP/1.1 403 Forbidden
Cache-Control: no-store

{ "error": "login_required" }

The app signs the player in again (any method) and retries. The browser SDK rejects with Auth4BrowserError code login_required. Unity throws AddEmailFailure.LoginRequired or LinkAccountFailure.LoginRequired. The native guest upgrade has no such check: a guest has no other way to sign in again, and the upgrade only ever adds a first sign-in method to a guest.

Notices and sign-out

Completed change Notice to Sessions ended
add email none (the account has no verified address) the creating session (as before)
change email the old address (email_changed) every session of the account
remove email the removed address (email_removed) every session of the account
link a provider the account’s address (account_linked) the creating session (as before)
unlink a provider the account’s address (account_unlinked) every session of the account
native guest upgrade none (a guest has no address) the guest session (as before)

“Every session” means the AUTH4D-211 rule: every session of the user in the tenant, with its refresh family and any hosted-login browser session, on every application. The app that made the change still collects a fresh token set by polling, created after the revocation.

The notice goes through the encrypted email outbox as template account-notice. It names the kind of change, the provider and the UTC time, says when other sessions were signed out, and points to the application’s support URL. It holds no link to act on and never contains the new address or a provider subject. Delivery is best effort: the change has committed, so an enqueue failure neither undoes it nor changes the response. The application’s email branding (name and support URL) applies as for magic links. EmailDelivery.sendAccountNotice is an optional port method.

Errors added

Status error Where
403 login_required create an email or link intent from a stale session
403 access_denied begin on an unbound intent without the verifier

Tests

tests/integration/auth4d-343.intent-binding.test.ts covers the following on real D1, Durable Objects and the email outbox:

  • forwarded handles for all three intents;
  • the app-made verifier;
  • stale sessions for all five credential-changing actions;
  • notices and sign-out for change, remove, link and unlink.

tests/e2e/auth4d-149.add-email.spec.ts checks that the hosted page sends the fragment’s verifier to begin only, stores nothing, and shows the account at 320 px.

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