AUTH4D-143 Passkeys (WebAuthn) on the hosted login
Players can save a passkey after signing in with an email code, Discord or Steam, and then sign in with that passkey. An application can also let a passkey create a new account. Passkeys are off for every application until a project owner or admin turns them on.
Relying party
- The RP ID is the host of the auth origin:
auth.auth4.devin production andauth.staging.auth4.devon staging. The expected origin is the auth origin itself. Passkeys saved on one environment cannot be used on another. - Attestation is
none. Registrations with any other attestation format are refused. - Passkeys are discoverable credentials (
residentKey: "required"), and user verification is required for every registration and sign-in. - ES256 (
-7) and RS256 (-257) public keys are accepted. - Every ceremony checks the client data type, challenge, origin and
crossOrigin, the RP ID hash, and the user-present and user-verified flags. Sign-in also checks the signature, the user handle (when the authenticator returns one) and the signature counter. A counter that does not move forward is refused unless both the stored and the new counter are 0; the refusal is audited aspasskey.sign_inwith outcomedenied.
Storage
Migration 0021_passkeys.sql adds:
passkey_credentials, keyed by(tenant_id, passkey_id), withcredential_idunique per tenant. It stores the COSE public key, algorithm, signature counter, transports, AAGUID and backup flags, the application where the passkey was added, and timestamps. Deleting a user deletes their passkeys.clients.passkeys_enabledandclients.passkey_sign_up, both defaulting to0.
Challenges are never stored in D1. Each one is a single-use entry in the tenant’s Durable Object state store, bound to the browser flow and its flow cookie, and expires after five minutes or with the flow, whichever is sooner.
Ceremony endpoints
All four endpoints are POST, take and return JSON with Cache-Control: no-store, and are only
accepted from the hosted login: the Origin header must be the auth origin and the request must
carry the browser flow’s __Host-auth4_flow_{flowId} cookie. Binary values use the WebAuthn JSON
encoding (unpadded base64url). A flow may start at most 20 ceremonies.
Passkeys are offered only to enabled browser applications with passkeys turned on, and never in a guest-linking flow. Game applications sign in on the device page, which does not offer passkeys.
POST /t/{tenantId}/passkeys/registration/options
{
"client_id": "cli_…",
"browser_flow_id": "flw_…",
"proof_id": "prf_…",
"proof_purpose": "email_verification"
}
- With a proof (
email_verification,discord_loginorsteam_login), the signed-in user enrols a passkey. The proof is only read: it stays valid for/oauth/authorize/continue. Guests cannot enrol a passkey; they keep their account by upgrading. - Without a proof, the request is a passkey sign-up. It is refused unless the application allows passkey sign-up and the project allows sign-up. No user exists until the passkey is verified.
200:
{
"options": {
"challenge": "…",
"rp": { "id": "auth.auth4.dev", "name": "Starfall Studios" },
"user": { "id": "…", "name": "player@example.com", "displayName": "player@example.com" },
"pubKeyCredParams": [
{ "type": "public-key", "alg": -7 },
{ "type": "public-key", "alg": -257 }
],
"timeout": 300000,
"attestation": "none",
"authenticatorSelection": {
"residentKey": "required",
"requireResidentKey": true,
"userVerification": "required"
},
"excludeCredentials": [{ "type": "public-key", "id": "…", "transports": ["internal"] }]
}
}
user.id is the auth4.dev user ID as UTF-8 bytes. user.name is the verified email address when
there is one, and player- plus the end of the user ID otherwise. Errors: 400 invalid_request,
400 unauthorized_client, 403 access_denied (passkeys off, sign-up not allowed, a guest, or a
bad flow or proof), 409 passkey_limit (the user already has 10 passkeys), 429 slow_down. Sign-up
has further limits (AUTH4D-344 (repository file docs/api-spec/auth4d-344.md)).
POST /t/{tenantId}/passkeys/registration/verify
{
"client_id": "cli_…",
"browser_flow_id": "flw_…",
"credential": {
"id": "…",
"rawId": "…",
"type": "public-key",
"response": { "clientDataJSON": "…", "attestationObject": "…", "transports": ["internal"] },
"authenticatorAttachment": "platform",
"clientExtensionResults": {}
}
}
201 after an enrolment: { "status": "registered" }. After a passkey sign-up the response also
carries a one-time proof:
{
"status": "registered",
"proof": { "proof_id": "prf_…", "purpose": "passkey_login", "expires_in": 300 }
}
The registration is audited as passkey.registered (target type passkey) with metadata
user_id, client_id, sign_up and backed_up. Errors: 400 invalid_passkey (a WebAuthn check
failed), 403 access_denied (the challenge was already used, belongs to another flow, or expired),
409 passkey_exists (the credential is already registered in this project), 409 passkey_limit.
POST /t/{tenantId}/passkeys/authentication/options
{ "client_id": "cli_…", "browser_flow_id": "flw_…" }
200:
{
"options": {
"challenge": "…",
"rpId": "auth.auth4.dev",
"timeout": 300000,
"userVerification": "required",
"allowCredentials": []
}
}
POST /t/{tenantId}/passkeys/authentication/verify
{
"client_id": "cli_…",
"browser_flow_id": "flw_…",
"credential": {
"id": "…",
"rawId": "…",
"type": "public-key",
"response": {
"clientDataJSON": "…",
"authenticatorData": "…",
"signature": "…",
"userHandle": "…"
}
}
}
200: { "proof": { "proof_id": "prf_…", "purpose": "passkey_login", "expires_in": 300 } }.
Passkeys are looked up only within the tenant in the path, so a passkey from another project, or
one that was revoked, is 400 invalid_passkey. A disabled user is 403 access_denied.
Continuing the authorization
POST /oauth/authorize/continue now accepts proof_purpose: "passkey_login". A passkey_login
proof names the user but no identity, because a passkey is not an identity record.
Flows (AUTH4D-158)
Flows events and triggers gain the method passkey:
user.signed_inand thepost_logintrigger usemethod: "passkey"when the authorization continues with apasskey_loginproof. The context names no identity provider.- A passkey sign-up runs the
pre_registrationtrigger withmethod: "passkey"before the user is stored (a denial is403 access_deniedand nothing is stored), then publishesuser.createdwithdata.method: "passkey". - Enrolling a passkey on an existing account raises no Flows event.
Login context
GET /t/{tenantId}/login/context adds two optional login_methods fields, present (and true)
only when they apply, so earlier callers see an unchanged shape:
passkey: the application offers passkeys and the flow is not a guest-linking flow.passkey_sign_up: a passkey may also create an account (the application allows it and the project allows sign-up).
Management
Application policy
GET and PATCH /management/tenants/{tenantId}/clients/{clientId} include, for browser
applications only:
{ "passkeys": { "enabled": true, "allow_sign_up": false } }
PATCH accepts passkeys with either field; omitted fields are unchanged. Setting it on a game
application is 400 passkeys_unsupported. A change is audited as
management.client.passkeys.updated with metadata passkeys and passkey_sign_up
(enabled/disabled); re-sending the same values writes nothing.
User passkeys
GET /management/tenants/{tenantId}/users/{userId}/passkeys (scope user:read):
{
"passkeys": [
{
"id": "pky_…",
"client_id": "cli_…",
"created_at": "2026-10-06T12:00:00.000Z",
"last_used_at": null,
"backed_up": true,
"transports": ["internal", "hybrid"]
}
]
}
Public keys, credential IDs and signature counters are never returned.
POST /management/tenants/{tenantId}/users/{userId}/passkeys/{passkeyId}/revoke (scope
user:write, project owner only, body {}) deletes the passkey and returns
{ "revoked": true }. It is audited as management.user.passkey_revoked (target type passkey).
An unknown passkey, or one that belongs to another user or project, is 404 not_found. The user’s
sessions are not revoked; use the session routes for that.
Source: docs/api-spec/auth4d-143.md