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:

  1. The application creates an intent with the player’s access token and names the provider and the action (link or unlink). It receives a non-secret intent handle, a poll secret and the hosted page URL.
  2. The application opens link_uri (/login/link-account?…&intent={handle}): a window in the browser SDK, the system browser in Unity.
  3. 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.
  4. The application polls with the poll secret. It gets authorization_pending until 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: and link-account-lock: keys, always with the tenant ID), the first-browser binding through __Host-auth4_flow_{flow}, and the same poll budget and slow_down rules. The AUTH4D-212 helpers decide whether another way to sign in remains and what auth_time the replacement session keeps.
  • The provider round trip is the guest upgrade’s linking flow (AUTH4D-67 (repository file docs/api-spec/auth4d-67.md)): begin creates 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 /login with a steam_login, discord_login or epic_login proof 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. complete moves 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 gets identity_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 returns LinkAccountIntentBeginResponseSchema: { "status": "pending", "action": "link", "provider": "steam", "expires_in": 580, "linked": ["email"] }. linked names the kinds of sign-in method the account has, once each. Repeating begin from the same browser returns the same answer (this is how the page continues after the provider return); any other browser gets 403 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.linked and 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.unlinked in the same transaction, revokes the session that created the intent and returns { "status": "unlinked" }. Errors: 409 only_sign_in_method and 409 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 sessionStorage and goes to the provider. On return, /login hands the proof (or the provider’s error code) back to this page, which asks Link this Steam account? before calling complete. 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) and unlinkAccount(provider) open the window inside the click, create the intent, poll, replace the in-memory token set and emit an account_linked or account_unlinked session event. They reject with guest_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_failed or unlink_account_failed. linkedAccounts() returns the list.
  • Unity: Auth4Client.LinkAccountAsync(LinkableProvider) and UnlinkAccountAsync(LinkableProvider) open the page with the BrowserLauncher, poll and commit the refreshed session. Failures throw Auth4LinkAccountException with a LinkAccountFailure reason.

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 same sub; an account owned by another user answers identity_in_use and 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 with npx vitest run --config vitest.config.ts --no-file-parallelism --testTimeout=30000 auth4d-308 in tests/integration.
  • packages/sdk-browser/src/link-account.test.ts and packages/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