{"_id":"@activescott/auth-provider-passkey","_rev":"5-43a02b0e916737eeef0476f8683fb133","name":"@activescott/auth-provider-passkey","dist-tags":{"latest":"2.2.0"},"versions":{"0.1.0":{"name":"@activescott/auth-provider-passkey","version":"0.1.0","keywords":["auth","authentication","passkey","webauthn","fido2"],"author":{"name":"Scott Willeke"},"license":"MIT","_id":"@activescott/auth-provider-passkey@0.1.0","maintainers":[{"name":"activescott","email":"scott@willeke.com"}],"homepage":"https://github.com/activescott/auth#readme","bugs":{"url":"https://github.com/activescott/auth/issues"},"dist":{"shasum":"e5105170f2a0e6d931927efdef55dd2ab4bb8f9e","tarball":"https://registry.npmjs.org/@activescott/auth-provider-passkey/-/auth-provider-passkey-0.1.0.tgz","fileCount":39,"integrity":"sha512-diYyZxpYS1KJT7HIx9sd6O8AXmXDSg2VFjRjG+98VN3NpRBZ0hS+otTMlTzSgoV3JQpW8gFr6VGHaQZjWOklXg==","signatures":[{"sig":"MEYCIQDXF0IL1iKMemABiRhIXQ37vIYg2u3FYvTIvFuibmariwIhAIsxTxpe/wpj60A/wF1OchV9d2PvP2SGLLqaoSmmWpZK","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":78946},"main":"./dist/index.js","type":"module","types":"./dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"},"./browser":{"types":"./dist/browser.d.ts","import":"./dist/browser.js"}},"gitHead":"248dd93ce638d887a6732d0aca0c30a2aba866f8","scripts":{"dev":"tsc --watch","test":"vitest run","build":"tsc","typecheck":"tsc --noEmit","prepublishOnly":"npm run build"},"_npmUser":{"name":"activescott","email":"scott@willeke.com"},"repository":{"url":"git+https://github.com/activescott/auth.git","type":"git","directory":"packages/auth-provider-passkey"},"_npmVersion":"10.9.8","description":"Passkey (WebAuthn) provider for @activescott/auth","directories":{},"_nodeVersion":"22.23.2","dependencies":{"zod":"^4.3.5","jose":"^6.2.6","@simplewebauthn/server":"^13.2.2"},"_hasShrinkwrap":false,"devDependencies":{"vitest":"^4.0.16","typescript":"^5.7.2","@types/node":"^22","@activescott/auth":"*"},"peerDependencies":{"@activescott/auth":"^3.0.0"},"_npmOperationalInternal":{"tmp":"tmp/auth-provider-passkey_0.1.0_1785650829614_0.57918851658847","host":"s3://npm-registry-packages-npm-production"}},"1.0.0":{"name":"@activescott/auth-provider-passkey","version":"1.0.0","keywords":["auth","authentication","passkey","webauthn","fido2"],"author":{"name":"Scott Willeke"},"license":"MIT","_id":"@activescott/auth-provider-passkey@1.0.0","maintainers":[{"name":"activescott","email":"scott@willeke.com"}],"homepage":"https://github.com/activescott/auth#readme","bugs":{"url":"https://github.com/activescott/auth/issues"},"dist":{"shasum":"d89b0fdc37328c27e09bdb3431fb4ddbbfd39cb5","tarball":"https://registry.npmjs.org/@activescott/auth-provider-passkey/-/auth-provider-passkey-1.0.0.tgz","fileCount":35,"integrity":"sha512-7F0tSTBuSv8t2snZrdzWyDcHhHCQSJ+EV0PTgu2zZG0ArlMbY0vgxWgJy1uaB31E1OpGW0Uyghkt3O3cVuQG4A==","signatures":[{"sig":"MEYCIQD3T0z+5PcVfDWDOXeGh2WO535ChGhmMNkfzdc6LTS6KwIhAPueEJWeWCgQ7ugRrwGyW7ruIS97G68Wq9fjFG0jdbyt","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@activescott%2fauth-provider-passkey@1.0.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":77104},"main":"./dist/index.js","type":"module","types":"./dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"},"./browser":{"types":"./dist/browser.d.ts","import":"./dist/browser.js"}},"gitHead":"843a5ec640514b2cda1ca6de8c63eec0ef9a69ed","scripts":{"dev":"tsc --watch","test":"vitest run","build":"tsc","typecheck":"tsc --noEmit","prepublishOnly":"npm run build"},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:660a9422-e84f-47d8-99dc-16a764839417"}},"repository":{"url":"git+https://github.com/activescott/auth.git","type":"git","directory":"packages/auth-provider-passkey"},"_npmVersion":"11.16.0","description":"Passkey (WebAuthn) provider for @activescott/auth","directories":{},"_nodeVersion":"24.18.0","dependencies":{"zod":"^4.3.5","jose":"^6.2.6","@simplewebauthn/server":"^13.2.2"},"_hasShrinkwrap":false,"devDependencies":{"vitest":"^4.0.16","typescript":"^5.7.2","@types/node":"^22","@activescott/auth":"*"},"peerDependencies":{"@activescott/auth":"^4.0.0"},"_npmOperationalInternal":{"tmp":"tmp/auth-provider-passkey_1.0.0_1786208140671_0.04869270749776322","host":"s3://npm-registry-packages-npm-production"}},"2.0.0":{"name":"@activescott/auth-provider-passkey","version":"2.0.0","keywords":["auth","authentication","passkey","webauthn","fido2"],"author":{"name":"Scott Willeke"},"license":"MIT","_id":"@activescott/auth-provider-passkey@2.0.0","maintainers":[{"name":"activescott","email":"scott@willeke.com"}],"homepage":"https://github.com/activescott/auth#readme","bugs":{"url":"https://github.com/activescott/auth/issues"},"dist":{"shasum":"d12f4ad63c6de916eea97a231db4b9bde83ed7cf","tarball":"https://registry.npmjs.org/@activescott/auth-provider-passkey/-/auth-provider-passkey-2.0.0.tgz","fileCount":35,"integrity":"sha512-wdnDGjI1GRv0jnruVWcQpJW7GWOkGZsmdyihHZm3gkpv/cJ4LbNwNCq25zgx7ty8ELPe+S8TJNo60LORubXhhw==","signatures":[{"sig":"MEYCIQDDR82RP83sR452vl3mD82ISmd+9Aekm5nuKdumxFDh8QIhAJ5KEyw/fGiKbCHY+Bs9b4/w0SZ869gTziTuu5SrnlOd","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@activescott%2fauth-provider-passkey@2.0.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":77755},"main":"./dist/index.js","type":"module","types":"./dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"},"./browser":{"types":"./dist/browser.d.ts","import":"./dist/browser.js"}},"gitHead":"f52acc55d81da8a0b3f8cbe8314f9b65ac02bc9c","scripts":{"dev":"tsc --watch","test":"vitest run","build":"tsc","typecheck":"tsc --noEmit","prepublishOnly":"npm run build"},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:660a9422-e84f-47d8-99dc-16a764839417"}},"repository":{"url":"git+https://github.com/activescott/auth.git","type":"git","directory":"packages/auth-provider-passkey"},"_npmVersion":"11.16.0","description":"Passkey (WebAuthn) provider for @activescott/auth","directories":{},"_nodeVersion":"24.18.0","dependencies":{"zod":"^4.3.5","jose":"^6.2.6","@simplewebauthn/server":"^13.2.2"},"_hasShrinkwrap":false,"devDependencies":{"vitest":"^4.0.16","typescript":"^5.7.2","@types/node":"^22","@activescott/auth":"*"},"peerDependencies":{"@activescott/auth":"^5.0.0"},"_npmOperationalInternal":{"tmp":"tmp/auth-provider-passkey_2.0.0_1786467601860_0.6798148120366445","host":"s3://npm-registry-packages-npm-production"}},"2.1.0":{"name":"@activescott/auth-provider-passkey","version":"2.1.0","keywords":["auth","authentication","passkey","webauthn","fido2"],"author":{"name":"Scott Willeke"},"license":"MIT","_id":"@activescott/auth-provider-passkey@2.1.0","maintainers":[{"name":"activescott","email":"scott@willeke.com"}],"homepage":"https://github.com/activescott/auth#readme","bugs":{"url":"https://github.com/activescott/auth/issues"},"dist":{"shasum":"fe17f83b1221101a9f6cbc02df352434490a2d37","tarball":"https://registry.npmjs.org/@activescott/auth-provider-passkey/-/auth-provider-passkey-2.1.0.tgz","fileCount":47,"integrity":"sha512-mWamO88W9krwRP1a9YoAalRYZr7TGBwNs7a0OobWFYJFIs+gqN2Gic6+3nV6GZxYxsVWkG0Az7LwLesqLbL4kw==","signatures":[{"sig":"MEQCID9GlbDLVEJe+RQN+Tg/XwqnehvNRpCNn0Mzv/FNiGslAiAS2HNhzFhX1Pm7u8pH1/RgqfMGUquF7n7TGTtMGtPOFA==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"sig":"MEUCIQCq9Osh/sNMA20l/wz2UV1URYTq4P6+LWqbzfBfadV4sQIgL4aRiQ7ZyL2gnlyU6CtmbPELrdbhvdfjGzKn2xAl/A0=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@activescott%2fauth-provider-passkey@2.1.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":98411},"main":"./dist/index.js","type":"module","types":"./dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"},"./browser":{"types":"./dist/browser.d.ts","import":"./dist/browser.js"}},"gitHead":"b6f1cba49be03af4df79446abb23fe24eb0e4b8f","scripts":{"dev":"tsc --watch","test":"vitest run","build":"tsc","typecheck":"tsc --noEmit","prepublishOnly":"npm run build"},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:660a9422-e84f-47d8-99dc-16a764839417"}},"repository":{"url":"git+https://github.com/activescott/auth.git","type":"git","directory":"packages/auth-provider-passkey"},"_npmVersion":"11.19.0","description":"Passkey (WebAuthn) provider for @activescott/auth","directories":{},"_nodeVersion":"24.20.0","dependencies":{"zod":"^4.3.5","jose":"^6.2.6","@simplewebauthn/server":"^13.2.2"},"_hasShrinkwrap":false,"devDependencies":{"vitest":"^4.1.11","typescript":"^5.7.2","@types/node":"^22","@activescott/auth":"*"},"peerDependencies":{"@activescott/auth":"^5.0.0"},"_npmOperationalInternal":{"tmp":"tmp/auth-provider-passkey_2.1.0_1789802978837_0.5981300173962576","host":"s3://npm-registry-packages-npm-production"}},"2.2.0":{"_id":"@activescott/auth-provider-passkey@2.2.0","bugs":{"url":"https://github.com/activescott/auth/issues"},"dist":{"shasum":"03e135cb1cf9f161c766737b675b1a0fe5da0fcd","tarball":"https://registry.npmjs.org/@activescott/auth-provider-passkey/-/auth-provider-passkey-2.2.0.tgz","fileCount":55,"integrity":"sha512-iqqrNwOgbgsDgD6iA7gu5budAoPV/ipDGasUegMl8DT356u9FErwZQaWgjSYcPhQ1L+HBt1G/Zz4wzHvqVmpWQ==","signatures":[{"sig":"MEQCIATK2jXELYae4J0dJ7Hp1BBVBRECcZbpG91aRVtmsVb7AiAz55XX13u6IsM+zdPil+3zOWabXaYlo1PlfoOk/8AxHg==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQCgjeqocbIVHM2Xf/6Ms6Gc/+qqiQl33PmIAgIY/zsgMQIhAKCckAFv/hu+mtP4Ms0vM1l5rp49pLuiE830H2KGJcCm"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@activescott%2fauth-provider-passkey@2.2.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":119189},"main":"./dist/index.js","name":"@activescott/auth-provider-passkey","type":"module","types":"./dist/index.d.ts","author":{"name":"Scott Willeke"},"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"},"./browser":{"types":"./dist/browser.d.ts","import":"./dist/browser.js"}},"gitHead":"d0f7b5ce81fa5d2c42d750ec4ce0271d993fd751","license":"MIT","scripts":{"dev":"tsc --watch","test":"vitest run","build":"tsc","typecheck":"tsc --noEmit","prepublishOnly":"npm run build"},"version":"2.2.0","_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"660a9422-e84f-47d8-99dc-16a764839417"}},"homepage":"https://github.com/activescott/auth#readme","keywords":["auth","authentication","passkey","webauthn","fido2"],"repository":{"url":"git+https://github.com/activescott/auth.git","type":"git","directory":"packages/auth-provider-passkey"},"_npmVersion":"11.19.0","description":"Passkey (WebAuthn) provider for @activescott/auth","directories":{},"maintainers":[{"name":"activescott","email":"scott@willeke.com"}],"_nodeVersion":"24.21.0","dependencies":{"zod":"^4.6.5","jose":"^6.2.6","@simplewebauthn/server":"^13.2.2"},"_hasShrinkwrap":false,"devDependencies":{"vitest":"^4.1.11","typescript":"^5.7.2","@types/node":"^22","@activescott/auth":"*"},"peerDependencies":{"@activescott/auth":"^5.0.0"},"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/auth-provider-passkey_2.2.0_1791004134321_0.12943457234400735"}}},"time":{"created":"2026-08-02T06:07:09.457Z","modified":"2026-10-03T05:08:54.704Z","0.1.0":"2026-08-02T06:07:09.787Z","1.0.0":"2026-08-08T16:55:40.829Z","2.0.0":"2026-08-11T17:00:02.034Z","2.1.0":"2026-09-19T07:29:38.924Z","2.2.0":"2026-10-03T05:08:54.414Z"},"bugs":{"url":"https://github.com/activescott/auth/issues"},"author":{"name":"Scott Willeke"},"license":"MIT","homepage":"https://github.com/activescott/auth#readme","keywords":["auth","authentication","passkey","webauthn","fido2"],"repository":{"url":"git+https://github.com/activescott/auth.git","type":"git","directory":"packages/auth-provider-passkey"},"description":"Passkey (WebAuthn) provider for @activescott/auth","maintainers":[{"name":"activescott","email":"scott@willeke.com"}],"readme":"# @activescott/auth-provider-passkey\n\n[![npm version](https://img.shields.io/npm/v/@activescott/auth-provider-passkey.svg)](https://www.npmjs.com/package/@activescott/auth-provider-passkey)\n[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)\n\nPasskey (WebAuthn) provider for [`@activescott/auth`](https://www.npmjs.com/package/@activescott/auth). Users add a passkey while signed in (via email or SMS first), then sign in usernameless with Touch ID, Face ID, Windows Hello, Android, 1Password, iCloud Keychain, or a security key.\n\nServer-side WebAuthn verification uses [`@simplewebauthn/server`](https://simplewebauthn.dev/) (WebCrypto-based). A zero-dependency browser client ships as the `@activescott/auth-provider-passkey/browser` subpath export.\n\n## Usage\n\nServer wiring — **no new storage interface**; passkeys reuse the `IdentityStore` you already have:\n\n```ts\nimport { Auth, InMemoryChallengeStore } from \"@activescott/auth\"\nimport { PasskeyProvider } from \"@activescott/auth-provider-passkey\"\n\nconst auth = new Auth({\n  session: { secret: process.env.JWT_SECRET! /* ... */ },\n  userStore,\n  identityStore,\n  challengeStore: new InMemoryChallengeStore(), // DB-backed in production\n  providers: [\n    // other providers such as email and/or SMS go here too — users add\n    // a passkey while signed in, so another provider handles first sign-in\n    new PasskeyProvider({\n      rpName: \"MyApp\",\n      // Bind passkeys to your canonical domain in production; leave unset in\n      // dev so rpID and origin derive from each request (e.g. localhost).\n      appUrl:\n        process.env.NODE_ENV === \"production\" ? process.env.APP_URL : undefined,\n      challengeSecret: process.env.JWT_SECRET!,\n    }),\n  ],\n})\n```\n\nBrowser (all four endpoints are fetch/JSON — WebAuthn ceremonies run in page JavaScript, not form navigations). `createPasskeyClient` fetches the options, runs the ceremony, and posts the result:\n\n```ts\nimport { createPasskeyClient } from \"@activescott/auth-provider-passkey/browser\"\n\nconst passkeys = createPasskeyClient() // basePath defaults to \"/auth\"\n\n// Add a passkey (user must be signed in):\nawait passkeys.registerPasskey()\n\n// Sign in with a passkey:\nawait passkeys.signInWithPasskey()\nlocation.assign(\"/dashboard\") // session cookie is set\n```\n\nBoth throw when the user cancels or the server rejects the request. A server rejection's `Error.message` is the most specific text the response carries — the error's `details.reason` (e.g. `\"Unknown credential\"`, a passkey saved in a password manager whose identity the server no longer has), else its `message` — so it is fit to show the user.\n\n### Fetch options before the tap\n\nSafari (iOS and macOS) shows the passkey sheet only while the tap that asked for it is still being handled. Called on its own, `signInWithPasskey()` fetches the options first and only then calls `navigator.credentials.get`, and Safari fails that with `NotAllowedError`. Call `prepareSignIn()` when the sign-in UI mounts, and `prepareRegistration()` on the page with the \"Add a passkey\" button:\n\n```tsx\n// Create the client once, at module scope\nexport const passkeys = createPasskeyClient()\n\n// In the component that renders the button; the returned function is the cleanup\nuseEffect(() => passkeys.prepareSignIn(), [])\n```\n\nOnce the options have arrived, a tap reaches `navigator.credentials.get` (or `create`) before `signInWithPasskey()` (or `registerPasskey()`) returns. While preparing, the client:\n\n- fetches again before the challenge expires, every `optionsMaxAge` milliseconds (`createPasskeyClient({ optionsMaxAge })`, default 4 minutes, a minute under the default `challengeExpiry`). Set it below `challengeExpiry` if you change that.\n- fetches again after each attempt fails, since every challenge is single-use (see [Challenges](#challenges)), and after a passkey is added, since the next options exclude it.\n- never starts a second fetch while one is in flight, while the passkey sheet is open, or while an autofill request is bound to the options. Each options response replaces the challenge cookie, and there is one per origin.\n- drops its options when anything else on the origin fetches options, since their challenge is gone: the other prepare call, another client, or another tab (told over `BroadcastChannel`, or `localStorage` where that is missing). A hidden tab does not refresh, and fetches again when shown if its options are gone or stale.\n\nA tap still waits for a fetch, and Safari refuses the sheet that time, when it comes before the first options arrive, during a refresh, after something else replaced the cookie, or after the tab sat hidden or in the back/forward cache past `optionsMaxAge` and before its refetch lands. With a pending autofill request the options are not refreshed, so a tap after `optionsMaxAge` waits too. Two tabs that both stay visible, such as two windows side by side, cannot both be ready: the one that fetched last is. The client has no readiness signal to disable the button with. After a refused tap it fetches again, and the next tap works once that lands.\n\nWithout the prepare call, both methods still work. They fetch at the tap, which Chrome, Firefox, and Edge allow.\n\nFor conditional UI (passkey autofill on the login form), add `autocomplete=\"username webauthn\"` to your username/email input and start a conditional request on page load:\n\n```ts\nimport {\n  createPasskeyClient,\n  isConditionalUIAvailable,\n} from \"@activescott/auth-provider-passkey/browser\"\n\nif (await isConditionalUIAvailable()) {\n  // Resolves once the user picks a passkey from the autofill suggestions and\n  // the server has set the session cookie. A later signInWithPasskey() call\n  // (e.g. from a button) aborts this pending request.\n  await createPasskeyClient().signInWithPasskey({ conditional: true })\n  location.assign(\"/dashboard\")\n}\n```\n\n`startRegistration` and `startAuthentication` are still exported for code that runs the HTTP round trips itself, but are deprecated in favor of the client.\n\n## Endpoints\n\n| Endpoint                                  | Auth required | Purpose                                                                         |\n| ----------------------------------------- | ------------- | ------------------------------------------------------------------------------- |\n| `POST /auth/passkey/register-options`     | session       | Registration options for adding a passkey to the signed-in user                 |\n| `POST /auth/passkey/register-verify`      | session       | Verify the attestation, store the credential, link a passkey identity           |\n| `POST /auth/passkey/authenticate-options` | none          | Authentication options (empty `allowCredentials` → any discoverable credential) |\n| `POST /auth/passkey/authenticate-verify`  | none          | Verify the assertion and set the session cookie                                 |\n\nRegistration model: **add-passkey-while-signed-in**. Users sign in with another provider (email, SMS) first, then add a passkey from a settings/dashboard page; afterwards they can sign in usernameless. Passkey-first signup is not supported.\n\n## Configuration\n\n| Option                | Default                                  | Description                                                           |\n| --------------------- | ---------------------------------------- | --------------------------------------------------------------------- |\n| `rpName`              | (required)                               | Relying party name shown in authenticator prompts                     |\n| `appUrl`              | (unset)                                  | Canonical app URL (e.g. `\"https://myapp.example\"`); set in production |\n| `rpID`                | `appUrl` hostname, else request hostname | Relying party ID; overrides `appUrl` (e.g. a parent domain)           |\n| `expectedOrigin`      | `appUrl` origin, else request origin     | Expected WebAuthn origin; overrides `appUrl`                          |\n| `challengeSecret`     | (required)                               | Signs the short-lived challenge cookie                                |\n| `challengeExpiry`     | `\"5m\"`                                   | Challenge lifetime                                                    |\n| `challengeCookieName` | `\"auth_passkey_challenge\"`               | Challenge cookie name                                                 |\n\n## Storage: passkeys are identities\n\nEach passkey is an ordinary identity row — `{provider: \"passkey\", identifier: <base64url credential ID>}` — so your existing `IdentityStore` is the only storage involved. The credential's verification state (public key, signature counter, transports, device type, ...) lives in the row's provider-owned `Identity.metadata`. Your store treats that metadata as an opaque JSON blob: persist it unmodified and return it exactly as stored — the provider validates it with a [zod](https://zod.dev) schema on every read and writes it back wholesale via `IdentityStore.update` after each sign-in (counter + last-used). A typical identities table:\n\n```sql\nCREATE TABLE identities (\n  id          TEXT PRIMARY KEY,\n  user_id     TEXT NOT NULL REFERENCES users (id),\n  provider    TEXT NOT NULL,       -- 'email' | 'sms' | 'passkey' | ...\n  identifier  TEXT NOT NULL,       -- email, E.164 phone, or WebAuthn credential ID\n  metadata    JSONB NOT NULL DEFAULT '{}', -- provider-owned; opaque to the app\n  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),\n  verified_at TIMESTAMPTZ,\n  UNIQUE (provider, identifier)\n);\nCREATE INDEX identities_user_id ON identities (user_id);\n```\n\nMetadata may contain sensitive material — treat it like credential data (encryption at rest is a reasonable default). Integrity matters more than secrecy here: anyone who can write this column can register their own key, so guard writes accordingly.\n\nTo list a user's passkeys for a settings page, `listPasskeys` filters their identities to passkeys, validates each row's provider state (skipping invalid rows), and returns plain JSON you can hand straight to the page:\n\n```ts\nimport { listPasskeys } from \"@activescott/auth-provider-passkey\"\n\nconst passkeys = await listPasskeys(identityStore, user.id)\n// [{ credentialId, nickname: string | null, synced: boolean,\n//    createdAt: ISO string, lastUsedAt: ISO string | null }, ...]\n```\n\nThe markup is yours; `synced` is true for passkeys synced to a cloud keychain or password manager.\n\n## Challenges\n\nThe options endpoints set an HttpOnly, SameSite=Lax cookie containing a signed JWT (`challengeSecret`, 5-minute expiry) that binds the ceremony to the browser, and record the challenge in the core `challengeStore`. The verify endpoints require both and consume the stored challenge on the first redemption attempt — success or not — so every challenge is strictly single-use.\n\n`prepareSignIn()` and `prepareRegistration()` fetch options every few minutes while a page is open, and most of those challenges are never redeemed. `InMemoryChallengeStore` sweeps expired rows; a `ChallengeStore` you write has to delete rows past `expiresAt` itself.\n\n## Cross-platform notes\n\n- **Synced passkeys** (iCloud Keychain, Google Password Manager, 1Password) report `deviceType: \"multiDevice\"` and usually a signature counter of 0. A counter regression is logged as a warning but does **not** fail authentication — synced passkeys regress counters legitimately, so blocking would lock out real users.\n- **rpID scoping**: a passkey is bound to its relying party ID. `localhost` works for development; production passkeys must be created on the production domain. Subdomains of the rpID can use the credential; a different registrable domain cannot.\n- **Authenticator choice is the user's**: options are generated with `residentKey: \"preferred\"`, `userVerification: \"preferred\"`, and no `authenticatorAttachment`, so platform authenticators, password managers, and roaming security keys all work.\n- **Algorithms**: ES256 and RS256 are accepted, covering Apple, Google, Microsoft, and common security keys.\n","readmeFilename":"README.md"}