`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.

<Callout title="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.
</Callout>

## Quick start

<Steps>

<Step>
### Plug it

```typescript title="src/app.ts"
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"] }));
```

</Step>

<Step>
### Register (session required)

Wire Bearer on `createClient` (`auth.getToken` / `memorySession`) — calls take input only.

```typescript
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`.

</Step>

<Step>
### Authenticate

```typescript
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`.

</Step>

</Steps>

## 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

<Accordions>
<Accordion title="register fails with unauthenticated">

Sign in with another method first. `userId` in the body must match the Bearer session.

</Accordion>
<Accordion title="authenticate returns invalid_origin">

`clientDataJSON.origin` must be in `passkey({ origins })`. Default allows only localhost HTTP/S.

</Accordion>
<Accordion title="authenticate returns user_not_verified">

AuthenticatorData UV bit was not set. Require user verification on the client
(`userVerification: "required"`) — the server never accepts UV=false.

</Accordion>
<Accordion title="authenticate returns reregister_required">

Signature counter did not increase (possible cloned authenticator). That credential was
deleted — register a fresh passkey for the account.

</Accordion>
<Accordion title="authenticate returns invalid_credentials">

Unknown `credentialId`, wrong/expired/`sessionId` mismatch, bad signature, wrong ceremony
type, or rpId hash mismatch. Re-run authenticate options for a fresh challenge + `sessionId`.

</Accordion>
</Accordions>

## Learn more

- [Two-factor](/docs/plugins/two-factor) — TOTP step-up
- [Gate](/docs/elements/gate) — `gate.auth`
- [Client · Auth](/docs/client/auth) — calling `/auth` from the browser

## Next

<Cards>
  <Card title="Two-factor" description="TOTP enable / verify." href="/docs/plugins/two-factor" />
  <Card title="Gate" description="Builtin auth and policies." href="/docs/elements/gate" />
  <Card title="Anonymous" description="Guest sessions." href="/docs/plugins/anonymous" />
</Cards>
