AUTH4D-146 Google, Apple and Microsoft sign-in (OpenID Connect)
auth4.dev can now act as an OpenID Connect relying party for three fixed presets: Google, Apple and Microsoft personal accounts. Each project registers its own application at the provider and stores the credentials with auth4.dev. There is no arbitrary, developer-defined OIDC provider; only these presets exist.
The Microsoft preset uses the /consumers endpoints, so it accepts personal Microsoft accounts
only. That is the account Xbox players sign in with. auth4.dev does not call Xbox services and does
not read gamertags or any other Xbox profile data.
| Preset | Issuer accepted in the ID token | Scopes | Response mode |
|---|---|---|---|
google |
https://accounts.google.com or accounts.google.com |
openid email |
query |
apple |
https://appleid.apple.com |
email |
form_post |
microsoft |
https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0 (consumer) |
openid email |
query |
Routes
GET /t/{tenantId}/identity/google/startGET /t/{tenantId}/identity/google/callbackGET /t/{tenantId}/identity/apple/startPOST /t/{tenantId}/identity/apple/callbackGET /t/{tenantId}/identity/apple/callbackGET /t/{tenantId}/identity/microsoft/startGET /t/{tenantId}/identity/microsoft/callback
Start
GET /t/{tenantId}/identity/{google|apple|microsoft}/start?client_id={clientId}&browser_flow_id={flowId}
is opened by the hosted pages. It accepts exactly those two parameters, once each, and requires the
__Host-auth4_flow_{flowId} HttpOnly browser cookie. As for Discord and Steam, the API consumes and
restores the tenant-scoped flow record and checks its tenant, client and cookie binding. It accepts
an OAuth authorization flow, a guest-linking flow, or a live device sign-in flow
(AUTH4D-124 (repository file docs/api-spec/auth4d-124.md)); native clients only in the last two. A missing cookie or a flow that
does not match is 403 access_denied. The tenant must be active and the client enabled, otherwise
400 unauthorized_client.
The provider must be enabled and fully configured for the project, and its stored secret must
decrypt. Otherwise start answers 403 access_denied, so a half-configured provider is never
offered.
The API creates three random 256-bit values and keeps them server-side in the tenant’s challenge
Durable Object, keyed by the hash of state:
state, sent to the provider and consumed on return, so every callback works once;- an S256 PKCE
code_verifier, of which only the challenge leaves the server; - a
nonce, which the returned ID token must carry.
The record also binds the tenant, preset, client, browser flow, browser-cookie hash, the exact
redirect URI and the provider client ID used. It expires with the flow, at most ten minutes. The
redirect to the provider carries response_type=code, client_id, redirect_uri, scope, state,
nonce, code_challenge and code_challenge_method=S256. Google and Microsoft also get
prompt=select_account, so a player signed in to several accounts can choose. Apple gets
response_mode=form_post. Apple’s documentation does not clearly say whether it enforces PKCE;
auth4.dev sends it anyway, and the nonce check protects the exchange either way.
The redirect URI is always {AUTH_ORIGIN}/t/{tenantId}/identity/{preset}/callback. Developers
register exactly that URL at the provider; the console shows it with a copy button.
Callback (Google and Microsoft)
GET /t/{tenantId}/identity/{google|microsoft}/callback accepts state plus exactly one of code
or error. error_description, error_uri, iss and scope are tolerated once each and ignored.
The provider’s error_description is never shown or echoed. Any other or repeated parameter is
400 invalid_request.
The state record is taken first. A missing, expired, tenant-mismatched or preset-mismatched state,
or a missing or different flow cookie, fails closed with 400 or 403 access_denied and no
redirect.
On a code, the Worker posts to the provider’s token endpoint with grant_type=authorization_code,
the code, the redirect URI, the client ID and secret and the PKCE code_verifier (5-second timeout).
It fetches the provider’s JWKS in parallel. The ID token is accepted only when all of these hold:
- the signature verifies against the provider’s JWKS with
RS256; no other algorithm is accepted; issis one of the preset’s issuers andaudis the project’s client ID;exp,iatandsubare present, and the token is within its lifetime, with 60 seconds of clock tolerance;nonceequals the one stored with the state.
The provider’s access token is discarded. If the project changed the provider’s client ID while
the player was away, or switched the provider off, the callback fails with
temporarily_unavailable.
Callback (Apple form_post)
Apple sends the result as a cross-site form post from appleid.apple.com, so the SameSite=Lax
flow cookie is not present on that request. The API splits the callback in two:
POST /t/{tenantId}/identity/apple/callbacktakesapplication/x-www-form-urlencodedwithstateand exactly one ofcodeorerror. Apple’s optionaluserfield (the player’s name, sent on the first sign-in only) is accepted and dropped. Anything else is400 invalid_request. The API consumes the one-time state, holds the code or error server-side for 120 seconds, and answers303toGET /t/{tenantId}/identity/apple/callback?state={state}&resume=1. This path accepts form posts from any origin and never sends CORS headers.- The browser follows the redirect as a top-level navigation, so the flow cookie is sent. The GET takes the held result, which makes the resume URL single-use, and checks the cookie binding before redeeming anything. Exchange and ID token checks are then as above.
The authorization code never appears in a URL. A code sent to the Apple GET callback is refused
with 400 invalid_request, and resume is refused for Google and Microsoft.
Result
The identity key is the ID token sub, stored per tenant as (tenant_id, provider, sub). An
existing identity signs in to its user; a new one creates a user. A disabled user is refused.
Accounts are never merged by email, so the same address at two providers gives two players.
The email address is stored lowercased when the provider sends one. It is marked verified only when
the provider says so: Google and Apple send email_verified. Microsoft sends no such claim, so a
Microsoft email is never marked verified. Apple may send a private relay address
(…@privaterelay.appleid.com) instead of the player’s own.
On success the callback redirects (302, Cache-Control: no-store, Referrer-Policy: no-referrer)
to /login, or to /login/device for a device sign-in flow, with tenant_id, client_id,
flow_id, a one-time proof_id and proof_purpose set to google_login, apple_login or
microsoft_login. On failure it carries error instead:
| Situation | error |
|---|---|
Player cancelled (access_denied, or Apple’s user_cancelled_authorize) |
access_denied |
| Bad signature, issuer, audience, lifetime or nonce; disabled user | access_denied |
| Other provider error, timeout, unexpected response, provider switched off | temporarily_unavailable |
POST /oauth/authorize/continue, the guest upgrade endpoints and POST /device/sign-in/resume
accept the three new proof purposes exactly like discord_login and steam_login. In a
guest-linking flow, only an identity created inside that flow moves to the guest, which keeps its
user ID. An account that already has its own user gives 409 identity_in_use.
Login methods and provider hint
login_methods in GET /t/{tenantId}/login/context and in the login_required answer of
GET /device/verify gains optional google, apple and microsoft keys. Each is present, and
true, only when that provider is enabled and configured:
{ "email": true, "discord": false, "steam": true, "google": true, "microsoft": true }
provider_hint on GET /oauth/authorize (AUTH4D-125 (repository file docs/api-spec/auth4d-125.md)) and provider on
/login/upgrade now also accept google, apple and microsoft. The browser SDK and Unity
package do not send these values yet; players pick the provider from the hosted list.
Project settings
PATCH /management/tenants/{tenantId} accepts three more objects under providers. Every field is
optional; unknown fields are 400.
| Object | Fields |
|---|---|
google |
enabled, clientId ({digits}-{id}.apps.googleusercontent.com), clientSecret |
microsoft |
enabled, clientId (the lowercase Application (client) ID GUID), clientSecret |
apple |
enabled, clientId (the Services ID, reverse-DNS), teamId and keyId (10 upper-case letters or digits), privateKey (the .p8 PKCS #8 PEM) |
Client secrets are 8 to 512 printable ASCII characters. The rules:
- A first configuration needs every credential: client ID and secret, or for Apple the Services ID,
team ID, key ID and private key. Otherwise
400 provider_credentials_required. - Changing the client ID (or Apple’s team or key ID) requires the secret or key again, also
400 provider_credentials_required. - Once configured,
{"enabled": true}orfalsealone is enough.
{
"providers": {
"google": {
"enabled": true,
"clientId": "123456789012-abc123.apps.googleusercontent.com",
"clientSecret": "…"
}
}
}
Secrets and the Apple key are write-only. They are encrypted with the deployment’s
AUTH_ENCRYPTION_KEY_RING (packages/crypto envelopes bound to the tenant and to the purpose
provider:google:client_secret, provider:microsoft:client_secret or provider:apple:signing_key)
and stored in oidc_provider_settings. If the deployment has no key ring the whole update is
refused with 503 temporarily_unavailable. The project view returns identifiers only:
{
"google": {
"enabled": true,
"clientId": "123456789012-abc123.apps.googleusercontent.com",
"clientSecretConfigured": true,
"clientSecretUpdatedAt": "2026-10-06T09:00:00.000Z"
},
"apple": {
"enabled": false,
"clientId": "com.example.game.signin",
"teamId": "ABCDE12345",
"keyId": "XYZ9876543",
"privateKeyConfigured": true,
"privateKeyUpdatedAt": "2026-10-06T09:00:00.000Z"
},
"microsoft": {
"enabled": false,
"clientId": null,
"clientSecretConfigured": false,
"clientSecretUpdatedAt": null
}
}
Apple’s client secret is not stored at all. For each token request the Worker signs a fresh ES256
JWT with the stored key: iss the team ID, sub the Services ID, aud https://appleid.apple.com,
a five-minute lifetime and kid the key ID.
Only project owners can change these settings (tenant:write). Each change writes a
management.provider.updated audit event with provider and status (enabled or disabled),
never the secret.
Storage
Migration 0017_oidc_providers.sql rebuilds identities to allow provider values google,
apple and microsoft, as 0011 did for Steam. Columns, keys, indexes and the Steam rule are
unchanged. It adds oidc_provider_settings, keyed by (tenant_id, provider), which cascades on
tenant deletion. See docs/operations/d1-schema.md.
Local development and tests
OIDC_LOCAL_MOCK_ORIGIN, honoured only when RUNTIME_ENV is local and only for a loopback
origin, sends all three presets to one stand-in. The preset name is the first path segment, for
example http://127.0.0.1:9100/google/token. Tests never contact the real providers.
packages/provider-oidc/src/index.test.ts: authorization URLs, PKCE, the Apple client-secret JWT, and ID token checks (algorithm, issuer, audience, lifetime, nonce, email verification).tests/integration/auth4d-146.oidc.test.ts: a local mock provider that enforces PKCE and client authentication. Covers sign-in for all three presets, Apple’s form_post and resume, replayed and mismatched state, project settings and secrets, guest upgrade andidentity_in_use, and device sign-in.
pnpm --filter @auth4/provider-oidc test
cd tests/integration && pnpm exec vitest run auth4d-146.oidc.test.ts
Source: docs/api-spec/auth4d-146.md