AUTH4D-308 link and unlink Steam, Discord and Epic Games accounts
A signed-in player can link a Steam, Discord or Epic Games account to their account, so either
sign-in reaches the same user, and can unlink one again. sub never changes and accounts are
never merged. The flow is a link intent, built like the add-email intent
(AUTH4D-149 (repository file docs/api-spec/auth4d-149.md), AUTH4D-212 (repository file docs/api-spec/auth4d-212.md)) and used by browser and native clients
alike:
- The application creates an intent with the player’s access token and names the provider and
the action (
linkorunlink). It receives a non-secret intent handle, a poll secret and the hosted page URL. - The application opens
link_uri(/login/link-account?…&intent={handle}): a window in the browser SDK, the system browser in Unity. - The hosted page binds the intent to that browser and shows the account’s Linked accounts. To link, it sends the player to the provider; the provider’s callback returns to hosted login with a one-time proof, and the player confirms. To unlink, the player only confirms.
- 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.
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.
Design
The link intent reuses existing primitives instead of adding new ones:
- Intent, polling and browser binding follow the add-email intent: SHA-256 hashes of the
handle and poll secret, a 600-second lifetime, every state change under a per-intent lock in the
tenant’s Durable Object (
link-account-intent:andlink-account-lock:keys, always with the tenant ID), the first-browser binding through__Host-auth4_flow_{flow}, and the same poll budget andslow_downrules. The AUTH4D-212 helpers decide whether another way to sign in remains and whatauth_timethe replacement session keeps. - The provider round trip is the guest upgrade’s linking flow (AUTH4D-67 (repository file
docs/api-spec/auth4d-67.md)):begincreates an OAuth flow whose redirect URI is the hosted login itself. The Steam, Discord and Epic Games start and callback routes already treat such a flow as a linking flow (a native game may run it) and return to/loginwith asteam_login,discord_loginorepic_loginproof bound to that flow. None of those routes changed. - Attaching the identity uses the guest upgrade’s provisional-identity rule. A callback for a
provider account that is new to the tenant creates a provisional user that owns only that
identity.
completemoves the identity to the player only when that user was created after the intent was bound, owns nothing else, and was never issued a session, membership, consent or passkey; and only while the player is enabled, is not a guest and has no identity of that provider. The move, the audit event and disabling the emptied user run in one D1 transaction. Any other proof subject is an existing account and getsidentity_in_use. - Guests keep using the guest upgrade, which also drops the guest claim. A link intent refuses
them with
guest_account.
Webhook events for linking were added later by AUTH4D-319 (repository file docs/api-spec/auth4d-319.md).
Create an intent (application)
POST /t/{tenantId}/account/link/intent
Authorization: Bearer {access token}
Content-Type: application/json
{ "client_id": "cli_…", "provider": "steam", "action": "link" }
provider is steam, discord or epic (LinkableProviderSchema). action is link or
unlink (LinkAccountActionSchema); without it the request means link. The bearer must be a
live access token for this tenant and client. Intents are budgeted at 10 per user per hour.
{
"intent": "lai_…",
"poll_secret": "lap_…",
"link_uri": "https://auth.auth4.dev/login/link-account?tenant_id=ten_…&client_id=cli_…&intent=lai_…",
"expires_in": 600,
"interval": 5
}
| Status | error |
When |
|---|---|---|
| 400 | invalid_request |
Malformed body, unknown provider or action |
| 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 | provider_already_linked |
link while the account already has an identity of the provider |
| 409 | provider_not_linked |
unlink while the account has no identity of the provider |
| 409 | only_sign_in_method |
unlink while it is the only way to sign in (AUTH4D-212 rule) |
| 409 | provider_disabled |
link while the provider is off for the project |
| 429 | slow_down |
Intent budget exhausted |
| 503 | temporarily_unavailable |
Session or atomic state unavailable |
Unlinking never visits the provider, so it also works after a project turned the provider off.
Wire shapes: LinkAccountIntentCreateRequestSchema and LinkAccountIntentCreateResponseSchema in
packages/contracts. The handle matches ^lai_[A-Za-z0-9_-]{32,64}$ and the poll secret matches
^lap_[A-Za-z0-9_-]{43,86}$.
List linked accounts (application)
POST /t/{tenantId}/account/identities
Authorization: Bearer {access token}
Content-Type: application/json
{ "client_id": "cli_…" }
{
"accounts": [
{ "identity_id": "idn_…", "provider": "email", "linked_at": "2026-10-01T09:30:00.000Z" },
{ "identity_id": "idn_…", "provider": "steam", "linked_at": "2026-10-22T14:02:11.000Z" }
]
}
The list never contains a provider subject (such as a SteamID) or an address, and never lists a
guest identity. Errors are those of intent creation up to invalid_session
(LinkedAccountsResponseSchema).
Poll for the result (application)
POST /t/{tenantId}/account/link/intent/token
Content-Type: application/json
{ "client_id": "cli_…", "intent": "lai_…", "poll_secret": "lap_…" }
Polling semantics, errors and budgets match the add-email intent: authorization_pending,
slow_down (adds 5 seconds, up to 60), access_denied once after Not now, expired_token,
invalid_grant for a wrong secret or a replay, and 429 slow_down past 12 polls per intent or 60
per IP each minute. The result is returned exactly once:
{
"user_id": "usr_…",
"identity": { "identity_id": "idn_…", "provider": "steam" },
"access_token": "…",
"token_type": "Bearer",
"expires_in": 300,
"refresh_token": "rfr_…",
"id_token": "…",
"scope": "openid offline_access",
"session_id": "ses_…"
}
An unlink result has no identity. The refreshed session has the same user, client and scopes as
the session that created the intent, keeps that session’s auth_time, and carries the account’s
verified address if it has one. The session that created the intent was revoked when the account
changed. refresh_token is present only when the scopes include offline_access.
CORS for browser applications
As for add-email intents, 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: POST with authorization and content-type for create
and list, and content-type only for poll. Responses never allow credentials.
Hosted page steps (browser)
Called by /login/link-account only. They require Origin: {AUTH_ORIGIN} (otherwise
403 access_denied) and never accept or return tokens.
POST /t/{tenantId}/account/link/intent/begin
POST /t/{tenantId}/account/link/intent/complete
POST /t/{tenantId}/account/link/intent/unlink
POST /t/{tenantId}/account/link/intent/deny
Content-Type: application/json
{ "client_id": "cli_…", "intent": "lai_…", "browser_flow_id": "flw_…" }
complete also takes "proof_id": "prf_…" (LinkAccountIntentCompleteRequestSchema); the others
use LinkAccountIntentBrowserRequestSchema.
- begin binds a pending intent to this browser, creates the linking flow, sets
__Host-auth4_flow_{browser_flow_id}(HttpOnly; Secure; SameSite=Lax) and returnsLinkAccountIntentBeginResponseSchema:{ "status": "pending", "action": "link", "provider": "steam", "expires_in": 580, "linked": ["email"] }.linkednames the kinds of sign-in method the account has, once each. Repeatingbeginfrom the same browser returns the same answer (this is how the page continues after the provider return); any other browser gets403 access_denied. - complete (link intents) requires the bound flow cookie and a proof of the intent’s provider,
minted for the same flow after binding. It moves the identity as described under
Design, revokes the session that created the intent, writes
identity.linkedand returns{ "status": "approved", "identity": { "identity_id", "provider" } }. Errors:409 identity_in_use(the provider account belongs to another user; nothing changes and the intent stays pending, so the player can try another account),409 provider_already_linked,400 access_denied(missing, expired, replayed, foreign or wrong-provider proof; the intent stays pending),400 invalid_request(an unlink intent),401 invalid_session(the user was disabled). - unlink (unlink intents) needs no proof, only the player’s confirmation. It deletes the
identity in one statement that re-checks that another way to sign in remains, writes
identity.unlinkedin the same transaction, revokes the session that created the intent and returns{ "status": "unlinked" }. Errors:409 only_sign_in_methodand409 provider_not_linked(the intent stays pending),400 invalid_request(a link intent),401 invalid_session. - deny ends the intent and returns
{ "status": "denied" }.
Errors common to the 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).
Audit events
Both are written with actor_id and target_id set to the user. Their metadata is limited to
IdentityLinkAuditMetadataSchema; it never holds the provider subject, an address or a token.
| Event | Metadata |
|---|---|
identity.linked |
client_id, provider, identity_id |
identity.unlinked |
client_id, provider, identity_id |
Hosted page
/login/link-account?tenant_id={tenantId}&client_id={clientId}&intent={handle}. A malformed link
shows the invalid-link page.
- Link: Link your Steam account with the Linked accounts list, Link Steam and
Not now. The page stores a non-secret marker under the flow ID in
sessionStorageand goes to the provider. On return,/loginhands the proof (or the provider’s error code) back to this page, which asks Link this Steam account? before callingcomplete. Outcomes: linked, that account is already in use (generic, with Use a different Steam account), cancelled at the provider, expired, already finished and opened in another browser. - Unlink: Unlink your Steam account? with the list, Unlink Steam and Not now, then the unlinked or only-way-to-sign-in outcomes.
The page keeps only the non-secret flow ID (keyed by handle) and the provider-return marker in
sessionStorage. It meets the hosted login CSP (no inline script or style).
SDKs
- Browser:
Auth4BrowserClient.linkAccount(provider)andunlinkAccount(provider)open the window inside the click, create the intent, poll, replace the in-memory token set and emit anaccount_linkedoraccount_unlinkedsession event. They reject withguest_account,provider_already_linked,provider_not_linked,provider_disabled,only_sign_in_method,account_window_open,cancelled,window_closed,popup_blocked,invalid_session,link_account_failedorunlink_account_failed.linkedAccounts()returns the list. - Unity:
Auth4Client.LinkAccountAsync(LinkableProvider)andUnlinkAccountAsync(LinkableProvider)open the page with theBrowserLauncher, poll and commit the refreshed session. Failures throwAuth4LinkAccountExceptionwith aLinkAccountFailurereason.
Tests
tests/integration/auth4d-308.link-accounts.test.ts: an email player links Steam through the real Steam start and callback routes (only Steam’s check endpoint is stubbed) and then signs in with Steam to the samesub; an account owned by another user answersidentity_in_useand changes nothing; foreign and replayed proofs and another browser; creation refusals and tenant isolation; expiry and Not now; unlinking, including the last-sign-in-method refusals; audit metadata; browser CORS. Run withnpx vitest run --config vitest.config.ts --no-file-parallelism --testTimeout=30000 auth4d-308intests/integration.packages/sdk-browser/src/link-account.test.tsandpackages/sdk-unity/Tests~/Auth4.Protocol.Tests/LinkAccountTests.cs: the SDK sides.tests/e2e/auth4d-308.link-accounts.spec.ts(port 5308): the hosted page, including the provider return, axe checks, keyboard focus, and 320px and 1280px layouts.
Source: docs/api-spec/auth4d-308.md