@activescott/auth-provider-passkey
Passkey (WebAuthn) provider for @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.
Server-side WebAuthn verification uses @simplewebauthn/server (WebCrypto-based). A zero-dependency browser client ships as the @activescott/auth-provider-passkey/browser subpath export.
Usage
Server wiring — no new storage interface; passkeys reuse the IdentityStore you already have:
import { Auth, InMemoryChallengeStore } from "@activescott/auth"
import { PasskeyProvider } from "@activescott/auth-provider-passkey"
const auth = new Auth({
session: { secret: process.env.JWT_SECRET! /* ... */ },
userStore,
identityStore,
challengeStore: new InMemoryChallengeStore(), // DB-backed in production
providers: [
// other providers such as email and/or SMS go here too — users add
// a passkey while signed in, so another provider handles first sign-in
new PasskeyProvider({
rpName: "MyApp",
// Bind passkeys to your canonical domain in production; leave unset in
// dev so rpID and origin derive from each request (e.g. localhost).
appUrl:
process.env.NODE_ENV === "production" ? process.env.APP_URL : undefined,
challengeSecret: process.env.JWT_SECRET!,
}),
],
})
Browser (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:
import { createPasskeyClient } from "@activescott/auth-provider-passkey/browser"
const passkeys = createPasskeyClient() // basePath defaults to "/auth"
// Add a passkey (user must be signed in):
await passkeys.registerPasskey()
// Sign in with a passkey:
await passkeys.signInWithPasskey()
location.assign("/dashboard") // session cookie is set
Both 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.
When the browser's passkey prompt ends without a credential (the user cancelled it, it timed out because the site has no passkey on this device, or another ceremony aborted it), the browser throws a NotAllowedError, AbortError, or TimeoutError whose text reads like a fault. describePasskeyError sorts a caught error into a typed result so you can show your own message instead:
import {
createPasskeyClient,
describePasskeyError,
} from "@activescott/auth-provider-passkey/browser"
try {
await passkeys.signInWithPasskey()
} catch (caught) {
const failure = describePasskeyError(caught)
// { ok: false, reason: "no-credential-or-cancelled" | "error", message, error }
showError(
failure.reason === "no-credential-or-cancelled"
? "No passkey for this site yet. Sign in with email, then add one."
: failure.message,
)
}
Browsers deliberately don't say which of those happened, so a site can't probe for passkeys; one message has to cover them all.
Fetch options before the tap
Safari (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:
// Create the client once, at module scope
export const passkeys = createPasskeyClient()
// In the component that renders the button; the returned function is the cleanup
useEffect(() => passkeys.prepareSignIn(), [])
Once the options have arrived, a tap reaches navigator.credentials.get (or create) before signInWithPasskey() (or registerPasskey()) returns. While preparing, the client:
- fetches again before the challenge expires, every
optionsMaxAgemilliseconds (createPasskeyClient({ optionsMaxAge }), default 4 minutes, a minute under the defaultchallengeExpiry). Set it belowchallengeExpiryif you change that. - fetches again after each attempt fails, since every challenge is single-use (see Challenges), and after a passkey is added, since the next options exclude it.
- 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.
- 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, orlocalStoragewhere that is missing). A hidden tab does not refresh, and fetches again when shown if its options are gone or stale.
A 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.
Without the prepare call, both methods still work. They fetch at the tap, which Chrome, Firefox, and Edge allow.
For 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:
import {
createPasskeyClient,
isConditionalUIAvailable,
} from "@activescott/auth-provider-passkey/browser"
if (await isConditionalUIAvailable()) {
// Resolves once the user picks a passkey from the autofill suggestions and
// the server has set the session cookie. A later signInWithPasskey() call
// (e.g. from a button) aborts this pending request.
await createPasskeyClient().signInWithPasskey({ conditional: true })
location.assign("/dashboard")
}
startRegistration and startAuthentication are still exported for code that runs the HTTP round trips itself, but are deprecated in favor of the client.
Endpoints
| Endpoint | Auth required | Purpose |
|---|---|---|
POST /auth/passkey/register-options | session | Registration options for adding a passkey to the signed-in user |
POST /auth/passkey/register-verify | session | Verify the attestation, store the credential, link a passkey identity |
POST /auth/passkey/authenticate-options | none | Authentication options (empty allowCredentials → any discoverable credential) |
POST /auth/passkey/authenticate-verify | none | Verify the assertion and set the session cookie |
Registration 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.
Configuration
| Option | Default | Description |
|---|---|---|
rpName | (required) | Relying party name shown in authenticator prompts |
appUrl | (unset) | Canonical app URL (e.g. "https://myapp.example"); set in production |
rpID | appUrl hostname, else request hostname | Relying party ID; overrides appUrl (e.g. a parent domain) |
expectedOrigin | appUrl origin, else request origin | Expected WebAuthn origin, or a list of them; overrides appUrl |
challengeSecret | (required) | Signs the short-lived challenge cookie |
challengeExpiry | "5m" | Challenge lifetime |
challengeCookieName | "auth_passkey_challenge" | Challenge cookie name |
To accept passkeys on several hosts that share one relying party ID, such as a site and its preview subdomains, pass a list. A passkey stays bound to rpID, so one saved on example.com works on all of them:
new PasskeyProvider({
rpName: "MyApp",
appUrl: "https://example.com", // rpID "example.com"
expectedOrigin: ["https://example.com", "https://beta.example.com"],
challengeSecret: process.env.JWT_SECRET!,
})
A list needs rpID or appUrl set. The constructor throws unless each http(s) origin is a bare origin (no path or trailing slash) on rpID or a subdomain of it, since browsers refuse a ceremony anywhere else. Non-web origins such as Android's android:apk-key-hash:... are passed through unchecked.
Storage: passkeys are identities
Each 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 schema on every read and writes it back wholesale via IdentityStore.update after each sign-in (counter + last-used). A typical identities table:
CREATE TABLE identities (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users (id),
provider TEXT NOT NULL, -- 'email' | 'sms' | 'passkey' | ...
identifier TEXT NOT NULL, -- email, E.164 phone, or WebAuthn credential ID
metadata JSONB NOT NULL DEFAULT '{}', -- provider-owned; opaque to the app
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
verified_at TIMESTAMPTZ,
UNIQUE (provider, identifier)
);
CREATE INDEX identities_user_id ON identities (user_id);
Metadata 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.
To 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:
import { listPasskeys } from "@activescott/auth-provider-passkey"
const passkeys = await listPasskeys(identityStore, user.id)
// [{ credentialId, nickname: string | null, synced: boolean,
// createdAt: ISO string, lastUsedAt: ISO string | null }, ...]
The markup is yours; synced is true for passkeys synced to a cloud keychain or password manager.
Challenges
The 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.
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.
Cross-platform notes
- 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. - rpID scoping: a passkey is bound to its relying party ID.
localhostworks for development; production passkeys must be created on the production domain. Subdomains of the rpID can use the credential; a different registrable domain cannot. - Authenticator choice is the user's: options are generated with
residentKey: "preferred",userVerification: "preferred", and noauthenticatorAttachment, so platform authenticators, password managers, and roaming security keys all work. - Algorithms: ES256 and RS256 are accepted, covering Apple, Google, Microsoft, and common security keys.