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.dev in production and auth.staging.auth4.dev on 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 as passkey.sign_in with outcome denied.

Storage

Migration 0021_passkeys.sql adds:

  • passkey_credentials, keyed by (tenant_id, passkey_id), with credential_id unique 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_enabled and clients.passkey_sign_up, both defaulting to 0.

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_login or steam_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_in and the post_login trigger use method: "passkey" when the authorization continues with a passkey_login proof. The context names no identity provider.
  • A passkey sign-up runs the pre_registration trigger with method: "passkey" before the user is stored (a denial is 403 access_denied and nothing is stored), then publishes user.created with data.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