AUTH4D-123 Steam sign-in
Steam sign-in is a hosted browser flow in which Steam acts as an OpenID 2.0 provider and auth4.dev is the relying party. Steam needs no client ID or secret and returns no email address, so a user created through Steam has no email until they link one. The identity key is the player’s SteamID64.
Start and callback
GET /t/{tenantId}/identity/steam/start?client_id={clientId}&browser_flow_id={flowId} requires
the __Host-auth4_flow_{flowId} HttpOnly browser cookie issued by /oauth/authorize (or by a guest
linking flow). As with Discord, the API consumes and restores the tenant-scoped authorization-flow
record to check its tenant, client and cookie binding, and ignores caller-supplied redirect URIs,
scopes and state. The tenant must be active, the client enabled for authorization code (native
clients only inside a guest-linking flow), and Steam enabled for the project. If Steam is not
enabled the start route answers 403 with access_denied.
The API redirects to https://steamcommunity.com/openid/login with openid.mode=checkid_setup,
identifier_select for both openid.identity and openid.claimed_id, openid.realm set to the
auth origin (for example https://auth.auth4.dev/), and openid.return_to set to
GET /t/{tenantId}/identity/steam/callback?state={state}. The state is a 256-bit random value
stored only as a hash in the tenant’s challenge Durable Object, bound to the authorization flow,
client, exact return_to and the hash of the browser cookie. It expires no later than the flow.
GET /t/{tenantId}/identity/steam/callback accepts state plus the openid.* parameters Steam
sends (ns, mode, op_endpoint, claimed_id, identity, return_to, response_nonce,
assoc_handle, signed, sig, invalidate_handle, error), each at most once; anything else is
400 invalid_request. The state record is consumed first, so every callback URL is single-use.
Missing, expired, tenant-mismatched and browser-mismatched state fails closed (400 or 403).
A positive assertion is accepted only when all of these hold:
openid.nsis the OpenID 2.0 namespace,openid.modeisid_resandopenid.op_endpointishttps://steamcommunity.com/openid/login;openid.return_toequals the storedreturn_toexactly, so it is on the auth origin and carries this flow’s state;openid.claimed_idmatches^https://steamcommunity\.com/openid/id/(7656119\d{10})$andopenid.identityequals it;openid.signedcoversop_endpoint,claimed_id,identity,return_to,response_nonceandassoc_handle, and the response nonce is no older than the browser-flow lifetime;- Steam answers
is_valid:truewhen the Worker posts the assertion back tohttps://steamcommunity.com/openid/loginwithopenid.mode=check_authentication(5-second timeout, redirects refused); - the SteamID64 and response nonce have not been accepted before. The nonce is recorded with an atomic put-if-absent in the tenant’s challenge Durable Object.
A tampered, unconfirmed or replayed assertion returns to /login with error=access_denied. A
cancelled sign-in (openid.mode=cancel) is also access_denied. A Steam timeout, network failure,
non-200 answer or unexpected body, or Steam being switched off while the player was away, returns
error=temporarily_unavailable. Error responses never include Steam’s response or the assertion.
On success the SteamID64 resolves to the tenant’s existing steam identity or creates a new user
with one, and the callback returns to /login with a one-time proof_id and
proof_purpose=steam_login. The hosted login passes it to /oauth/authorize/continue, which now
accepts proof_purpose steam_login alongside email_verification and discord_login.
Guest upgrade
Inside a guest-linking flow, a steam_login proof can be passed to the guest upgrade endpoints
(/t/{tenantId}/guest/upgrade and /t/{tenantId}/guest/upgrade-intent/complete) like an email or
Discord proof. The same rule applies: only a Steam account created inside this linking flow moves
to the guest, keeping the guest’s user ID. A SteamID64 that already belongs to another user gives
409 identity_in_use, and accounts are never merged.
Project setting and login context
Project owners enable Steam with
PATCH /management/tenants/{tenantId} {"providers": {"steam": {"enabled": true}}}; the project
view reports settings.providers.steam.enabled, and each change writes a
management.provider.updated audit event with provider: "steam". The setting is stored in
project_settings.steam_enabled (migration 0011), not in provider_configurations, because Steam
has no credentials. GET /t/{tenantId}/login/context adds "steam": true to login_methods only
when Steam is offered; otherwise the key is absent and the response is unchanged.
Migration 0011 rebuilds identities to allow provider = 'steam' (columns, keys and indexes are
unchanged; a Steam subject must be a 17-digit SteamID64 with no email) and adds
project_settings.steam_enabled, which defaults to 0.
Local development can point Steam at a loopback stand-in with STEAM_LOCAL_MOCK_ORIGIN, which the
Worker honours only when RUNTIME_ENV is local. Tests stub check_authentication and never
contact Steam.
Source: docs/api-spec/auth4d-123.md