Passkey
Official plugin — WebAuthn register and authenticate under /auth with UV, signCount, and ceremony session binding.
passkey() adds register and authenticate Flows for WebAuthn credentials
(oke_passkeys). Options return a challenge plus a ceremony sessionId;
register/authenticate verify client data, authenticator data, and ECDSA P-256.
The one rule
Enable gate.auth, then .plug(passkey()). Registration needs a Bearer session; authenticate is
public. Echo the options sessionId with every ceremony — UV=false assertions never mint a
session.
Quick start
Plug it
import { oke } from "okengine";
import { passkey } from "okengine/plugins";
export const app = oke({
name: "shop",
env: "dev",
gate: { auth: {} },
}).plug(passkey({ origins: ["http://localhost", "https://localhost"] }));Register (session required)
Wire Bearer on createClient (auth.getToken / memorySession) — calls take input only.
const opts = await api.auth.passkeyRegisterOptions({});
// opts.data: { challenge, sessionId, rpId, userId }
await api.auth.passkeyRegister({
credentialId: "...", // base64url
publicKey: "...", // base64url SPKI (ECDSA P-256)
userId: opts.data!.userId,
challenge: opts.data!.challenge,
sessionId: opts.data!.sessionId,
clientDataJSON: "...", // base64url JSON { type: "webauthn.create", challenge, origin }
authenticatorData: "...", // base64url (UP|UV required)
signature: "...", // base64url ECDSA over authData || SHA-256(clientDataJSON)
});Paths: POST /auth/passkey/register/options, POST /auth/passkey/register.
Authenticate
const opts = await api.auth.passkeyAuthenticateOptions({});
const { data } = await api.auth.passkeyAuthenticate({
credentialId: "...",
challenge: opts.data!.challenge,
sessionId: opts.data!.sessionId,
clientDataJSON: "...", // type must be "webauthn.get"
authenticatorData: "...",
signature: "...",
});Paths: POST /auth/passkey/authenticate/options, POST /auth/passkey/authenticate.
Challenges are single-use and bound to sessionId; wrong origin → invalid_origin; UV
missing → user_not_verified; cloned counter → reregister_required.
Server checks
| Check | Behavior |
|---|---|
| Ceremony type | clientDataJSON.type must be webauthn.create (register) or webauthn.get (authenticate) |
| User verification | AuthenticatorData UV bit must be set — UV=false is always rejected |
| Challenge binding | Challenge hash + sessionId must match; TTL ≤ 5 minutes; single-use |
| Signature counter | Stored per credential; if stored ≠ 0 and incoming signCount ≤ stored → delete credential, warn, reregister_required |
Options
| Option | Type | Default | Meaning |
|---|---|---|---|
secret | string | active* | HMAC secret (*from gate.auth) |
sessions | SessionStore | active* | Session store |
now | () => number | Date.now | Injectable clock |
passkeys | PasskeyStore | new | Credential → user mapping |
challenges | VerificationStore | new | Registration / auth challenges |
rpId | string | "localhost" | Relying party id |
origins | string[] | ["http://localhost","https://localhost"] | Allowed clientDataJSON.origin |
Surfaces
| Flow | Path | Gate |
|---|---|---|
auth.passkeyRegisterOptions | POST /auth/passkey/register/options | session + bearer |
auth.passkeyRegister | POST /auth/passkey/register | session + bearer |
auth.passkeyAuthenticateOptions | POST /auth/passkey/authenticate/options | gate.public + otp rate |
auth.passkeyAuthenticate | POST /auth/passkey/authenticate | gate.public + otp rate |
Consequence: a stolen credentialId without the private key (and UV) cannot mint a session.
Set origins to your real app origins before production.
Troubleshooting
Sign in with another method first. userId in the body must match the Bearer session.
clientDataJSON.origin must be in passkey({ origins }). Default allows only localhost HTTP/S.
AuthenticatorData UV bit was not set. Require user verification on the client
(userVerification: "required") — the server never accepts UV=false.
Signature counter did not increase (possible cloned authenticator). That credential was deleted — register a fresh passkey for the account.
Unknown credentialId, wrong/expired/sessionId mismatch, bad signature, wrong ceremony
type, or rpId hash mismatch. Re-run authenticate options for a fresh challenge + sessionId.
Learn more
- Two-factor — TOTP step-up
- Gate —
gate.auth - Client · Auth — calling
/authfrom the browser