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.
- Browser verifier. Every intent has a one-time verifier, and only the
beginthat presents it binds the intent to its browser. The handle alone (from a log, a URL bar or a forwarded request) binds nothing. - Account label. Once bound,
beginreturns whose account the intent changes, and the hosted page shows it before the player confirms anything. - 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. - 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