# passkify 0.2.0 — complete documentation Passkeys (WebAuthn) for a website. Client and server in one npm package, zero runtime dependencies. Node 20+, Bun, Deno, Cloudflare Workers, Vercel Edge. Source: https://github.com/AbnormalPilot/passkify Documentation: https://passkify.himanshubuilds.in/ Generated from the site's own source, so it cannot drift from what is published. --- # Introduction What passkify does, what it deliberately does not do, and how the pieces fit. ```bash npm install passkify ``` Written in TypeScript, shipped as both ESM and CommonJS, and about 6.5 kB gzipped in the browser. ## The problem it solves Passkeys are a good idea wrapped in a specification that is genuinely hard to implement. Three things make it harder than it looks: - The browser API hands you `ArrayBuffer` values that cannot survive `JSON.stringify`, so every field has to be base64url-encoded by hand before it can reach your server. - The server side needs a CBOR parser, COSE public-key handling, and signature verification across several algorithms, all operating on bytes an attacker controls. - Fifteen separate checks have to pass before an assertion means anything. Miss one and you get either a login that never works or, worse, a login that always works. passkify does that part. You get four server methods, two browser calls, and a storage interface. ## What it looks like #### server ```js import { PasskeyServer, MemoryStore } from 'passkify'; const passkeys = new PasskeyServer({ rpName: 'Acme', origin: 'https://acme.com', store: new MemoryStore(), }); app.use(passkeys.express({ onLogin: (req, res, { user }) => { req.session.userId = user.id; }, })); ``` #### browser ```js import { register, login } from 'passkify/client'; await register({ username: 'ada' }); // sign up await login(); // sign in, no username needed ``` That is a working passwordless login. ## What it deliberately does not do Knowing where the edges are saves you an afternoon. - **Session management** (`yours`) — A verified `finishAuthentication` means "this person proved possession of a registered credential, just now". Turning that into a session is your existing code's job, and passkify does not want to own your cookies. - **Account recovery** (`yours`) — The hardest part of going passwordless, and not something a library can decide. The [security guide](/docs/guides/security) covers the options. - **A database** (`yours`) — passkify never talks to storage directly. You implement [`PasskeyStore`](/docs/storage) against whatever you already run. - **Attestation trust** (`partial`) — Statements are verified when present, but passkify does not ship a FIDO Metadata Service blob, so it will not claim an authenticator is a genuine YubiKey. See [attestation](/docs/guides/security#attestation). ## Design decisions worth knowing up front ### Failure throws, it does not return a flag `finishRegistration` and `finishAuthentication` never return `{ verified: false }`. Any failure is a thrown [`PasskeyError`](/docs/errors). > **Why it works this way** > > A library that returns a result object invites `if (result.verified)`, and invites forgetting it. Forgetting it turns a failed verification into a successful login. There is no such shape here to forget. ### Challenges are single use, enforced by the store contract [`takeChallenge`](/docs/storage#takechallenge) fetches **and deletes**. It is called once per ceremony, and the delete happens whether verification then succeeds or fails. > **Why it works this way** > > Deleting on read is what stops a captured response from being replayed. Putting the delete inside the store method rather than in calling code means there is no path through the library that forgets to do it. ### Registration will not take over an existing username `startRegistration({ username })` throws if the account already exists. Adding a passkey to an existing account requires `startRegistration({ userId })`, and the mounted routes take that ID from your session. > **Why it works this way** > > Without this rule, anyone who knows a username could attach their own passkey to that account and sign in as them. It is the single most common way to get a passkey integration catastrophically wrong, so the API refuses to express it. ### Login does not confirm whether an account exists `startAuthentication({ username: 'nobody' })` returns a normal, well-formed challenge rather than a 404. > **Why it works this way** > > Answering honestly would turn the login form into a free account-enumeration oracle. The ceremony fails later at verification, exactly like any other bad attempt, and an attacker learns nothing. ## Where to go next - [Installation](/docs/installation) if you want the entry points and requirements. - [Quickstart](/docs/quickstart) for a running app in five minutes. - [How passkeys work](/docs/how-passkeys-work) if the concepts are new. Ten minutes here will save you an hour of debugging `rpID`. --- # Installation Install the package, pick your entry points, and check runtime requirements. ```bash npm install passkify ``` No peer dependencies, no post-install step, and nothing to configure at install time. ## Entry points The package has two halves that never run in the same place. - **passkify** (`server (Node)`) — The server API. Re-exports everything from `passkify/server`, because that is what a bare `import ... from 'passkify'` resolves to in a Node process. - **passkify/server** (`server (Node)`) — The same module, named explicitly. Prefer this in a codebase where server and client code sit side by side, since the import path then says which side of the wire a file belongs to. - **passkify/client** (`browser`) — The browser half. No Node built-ins, so it bundles anywhere. ```js import { PasskeyServer, MemoryStore } from 'passkify'; // Node import { register, login } from 'passkify/client'; // browser ``` > **Nothing stops you importing the server half into browser code** > > It used to stop itself: `passkify/server` pulled in `node:crypto` and a browser build failed on it. Verification is pure WebCrypto now, which is what makes the edge runtimes work — and the same change means the server half bundles for a browser without a word. You get the entire verifier, roughly six times the size of `passkify/client`, and no error. Import the subpath. ## Requirements - **Node** (``) — = 20"> Or Bun, Deno, Cloudflare Workers or Vercel Edge — verification is pure WebCrypto, so the server half is not tied to Node at all. - **Module format** (`ESM and CommonJS`) — Both builds ship. `import` resolves to the ESM build, `require` to the CommonJS one, and types come from a single set of declarations. - **TypeScript** (``) — = 5.0"> Types are bundled. `moduleResolution` must be `bundler`, `node16` or `nodenext` for the subpath exports to resolve. - **HTTPS** (`required in production`, required) — WebAuthn only runs in a secure context. `localhost` counts as one, which is why local development works over plain HTTP. Everything else needs real TLS. ### Browser support Passkeys work in every current browser. The features that are newer than the base API degrade rather than break. | Feature | Chrome | Safari | Firefox | Edge | | --- | --- | --- | --- | --- | | WebAuthn | 67 | 14 | 60 | 18 | | Passkeys (discoverable, synced) | 108 | 16 | 122 | 108 | | Conditional UI (autofill) | 108 | 16 | 119 | 108 | `passkify/client` feature-detects all of it. `isSupported()`, `isPlatformAuthenticatorAvailable()` and `isAutofillAvailable()` let you decide what to show; see the [client API](/docs/client). ## Runtime footprint - **Runtime dependencies** (`0`) — The only supply chain is Node's own `crypto`. CBOR decoding, COSE key parsing, attestation verification and base64url are all in the package. - **Browser bundle** (`~6.5 kB gzipped`) — That is `passkify/client` plus the shared encoding and error modules. The server half never reaches the browser. > **Why zero dependencies is a feature here, not a slogan** > > This code sits directly on attacker-controlled bytes. Every dependency in that path is code you are trusting without reading and a version you have to keep patched. The parser is about four hundred lines; that is small enough that reading it is a realistic thing to ask of a security reviewer, and small enough that shipping it beats importing it. ## Verify the install ```js import { PasskeyServer, MemoryStore } from 'passkify'; const passkeys = new PasskeyServer({ rpName: 'Test', origin: 'http://localhost:3000', store: new MemoryStore(), }); const { options } = await passkeys.startRegistration({ username: 'ada' }); console.log(options.rp.id); // 'localhost' console.log(options.challenge); // 43 base64url characters ``` If the constructor throws, read the message. Configuration errors are checked eagerly and name the fix; see [configuration](/docs/server/configuration). --- # Quickstart A working passwordless sign-up and sign-in, end to end, in about five minutes. A complete Express app with passwordless sign-up and sign-in. Copy it, run it, then replace the store. > **Prefer to read finished code, or just try it?** > > Every file of this app, plus the Next.js App Router version, is reproduced in full on the [examples page](/docs/examples). A working version is running at [/demo](/demo): it uses the published package against your own authenticator, so you can see the whole flow before writing anything. #### Install ```bash npm install passkify express express-session ``` #### Create the server ```js // server.js import express from 'express'; import session from 'express-session'; import { PasskeyServer, MemoryStore } from 'passkify'; const app = express(); app.use(express.json()); app.use(session({ secret: 'dev-only', resave: false, saveUninitialized: false })); const passkeys = new PasskeyServer({ rpName: 'Acme', origin: 'http://localhost:3000', // scheme + host + port, exactly as served store: new MemoryStore(), // development only }); ``` Three required options. `rpName` is the site name the operating system shows in the passkey prompt, `origin` is where your page is served from, and `store` is where credentials live. Everything else has a sensible default; see [configuration](/docs/server/configuration). #### Mount the routes ```js app.use( passkeys.express({ // Identifies the signed-in visitor. Enables adding a second passkey and // unlocks the credential-management routes. getSessionUserId: (req) => req.session.userId ?? null, // Start a session as soon as either ceremony succeeds. onRegister: (req, res, { user }) => { req.session.userId = user.id; }, onLogin: (req, res, { user }) => { req.session.userId = user.id; }, }), ); app.listen(3000); ``` This mounts four routes under `/passkey`. The middleware calls `next()` for anything else, so it is safe at the top of the stack. See [HTTP adapters](/docs/server/adapters) for the full route table. #### Call it from the browser ```html ``` #### Run it ```bash node server.js # open http://localhost:3000 ``` Your browser offers Touch ID, Windows Hello, or your phone. There is no password field anywhere in this app. > **Why localhost works without HTTPS** > > WebAuthn requires a secure context, and browsers classify `localhost` as one. Every other host needs real TLS. To test on a phone, use a tunnel; see [troubleshooting](/docs/guides/troubleshooting#testing-on-a-phone). ## What just happened #### register() asked your server for options `POST /passkey/register/start` returned a random challenge, your `rpID`, and a freshly minted user handle. No account was created yet. #### The browser created a key pair After Touch ID or Windows Hello confirmed a human was present, the authenticator generated a key pair, kept the private half, and signed a statement about the ceremony. #### Your server verified it and stored the public half `POST /passkey/register/finish` ran eighteen checks, and only then created the account and stored the credential. An abandoned prompt leaves nothing behind. #### login() did the same in reverse The browser showed a picker of passkeys for your domain, signed a fresh challenge with the private key, and your server verified that signature against the stored public key. There is no shared secret in that flow, so there is nothing in your database worth stealing and nothing for a phishing page to capture. ## Before you ship - **Replace MemoryStore** (`required`, required) — It loses everything on restart, and across multiple workers a challenge issued by one process is invisible to the others, so roughly every other login fails. See [storage](/docs/storage). - **Set a real session secret** (`required`, required) — From the environment, and set `cookie.secure = true` behind TLS. - **Rate limit the start routes** (`recommended`) — `/passkey/login/start` issues a challenge to anyone who asks, and each one costs a store write. - **Decide on account recovery** (`recommended`) — Prompt for a second passkey at signup. See [account recovery](/docs/guides/security#account-recovery). ## Next - [How passkeys work](/docs/how-passkeys-work) for the concepts under all this. - [Framework guides](/docs/guides/frameworks) for Next.js, Hono, SvelteKit and the rest. - [Storage](/docs/storage) to swap out `MemoryStore`. --- # How passkeys work Relying Party ID, challenges, user handles, discoverable credentials. Enough of the model to debug your own integration. Ten minutes here saves an hour of guessing at `rpID`. ## The one-paragraph version A passkey is a key pair. The private half never leaves the user's device, or their iCloud or Google keychain. Your server stores the public half. Signing in means your server sends a random challenge, the device signs it, and you verify the signature. There is no shared secret, so there is nothing in your database worth stealing and nothing for a phishing page to capture. ## The two ceremonies Both are two round trips, and both are the same shape: you issue a challenge, the device signs something, you verify. ``` REGISTRATION AUTHENTICATION browser server browser server | register/start | | login/start | |----------------->| challenge + rp info |--------------->| challenge |<-----------------| |<---------------| | | [ device creates a key pair, [ device signs the challenge signs, shows Touch ID ] with the stored private key ] | | | register/finish | | login/finish | |----------------->| verify + store pubkey |--------------->| verify signature |<-----------------| |<---------------| ``` ## The three terms that matter ### Relying Party ID The domain a passkey is bound to. A credential created for `example.com` cannot be used on `evil.com`. > **Why this is the whole security story** > > The browser enforces the binding, not your code and not the user's judgement. A pixel-perfect replica at `example.com.evil.net` cannot obtain a signature for `example.com`, because the browser will not ask the authenticator for one. This is why passkeys are unphishable in a way that one-time codes are not, and why `rpID` is the one setting you must get right. The rule is that `rpID` must be the page's host or a parent domain of it: | Page served from | Valid `rpID` | Invalid | | --- | --- | --- | | `https://example.com` | `example.com` | `app.example.com`, `www.example.com` | | `https://app.example.com` | `app.example.com`, `example.com` | `other.example.com` | | `http://localhost:3000` | `localhost` | `127.0.0.1` | > **You cannot change rpID later** > > Every credential is permanently bound to the `rpID` it was created under. Changing it orphans every passkey your users have registered, and they will all have to enrol again. If you might ever want one passkey to work across subdomains, set it to your apex domain **now**. ### Challenge Random bytes you generate, store, and send to the browser. The device signs them, and you check the signature is over the bytes you issued. > **Why it works this way** > > This is what makes each ceremony fresh. Without a server-issued challenge, a captured response would work forever. passkify uses 32 random bytes by default, stores each one exactly once, and deletes it on read, so a replay of a captured response finds nothing to match against. Challenges expire after five minutes by default. That window covers a slow user finding their phone without leaving a useful replay surface. ### User handle An opaque identifier for the account, stored on the user's device alongside the credential. On a usernameless login the device sends it back, which is how your server knows which account just signed in. > **Why it must not be an email address** > > User handles are stored in the clear on the authenticator and are visible in the user's password manager. They are also permanent: change the handle and every existing passkey for that account is orphaned. Use a UUID. passkify generates one for you and asks your store to persist it verbatim. Hard limit: 64 bytes once UTF-8 encoded. passkify checks this before the browser prompt appears rather than letting it fail opaquely. ## Discoverable credentials This is what makes `login()` work with no username. **Do this — Discoverable (resident key):** The authenticator stores the credential **and** the user handle. The browser can list every passkey for your domain, so it shows a picker and you learn who signed in from the response. ```js await login(); // no argument ``` **Not this — Non-discoverable:** The authenticator stores only a key derived from an ID you supply. You must already know which credentials to offer, which means asking for a username first. ```js await login({ username }); ``` passkify defaults to `residentKey: 'preferred'` and supports both flows. > **Why 'preferred' and not 'required'** > > `'required'` guarantees usernameless login but will fail outright on authenticators with limited storage, which includes a lot of older hardware security keys. `'preferred'` gets you a discoverable credential everywhere it is possible and quietly falls back everywhere it is not, so nobody is locked out. Set it to `'required'` only if you are willing to turn those users away. ## User presence and user verification Two different flags in every response, often confused. - **User Present (UP)** (`always required`, required) — Somebody physically interacted with the authenticator: a touch, a tap, a button press. It proves a human is there. It does not prove which human. - **User Verified (UV)** (`configurable`) — The authenticator checked *who* it was: a PIN, a fingerprint, a face. This is what makes a passkey two factors in one gesture, something you have plus something you are. passkify always requires UP. UV is governed by [`userVerification`](/docs/server/configuration#userverification), which defaults to `'preferred'`. ## Synced versus device-bound Every registration reports whether the credential can leave the device it was made on. - **multiDevice** (`backup eligible`) — A syncing passkey. It lives in iCloud Keychain, Google Password Manager, 1Password or similar, and survives the loss of any one device. Most consumer passkeys are this. - **singleDevice** (`not backup eligible`) — Bound to one authenticator, typically a hardware security key. Losing the device loses the credential, which matters for your recovery planning. passkify records this on every credential as `deviceType`, so an account settings page can warn a user whose only passkey is device-bound. See [credential management](/docs/server/credentials). ## Attestation, and why you should ignore it Attestation answers "what kind of authenticator made this key?". For consumer passkeys the answer is deliberately nothing: Apple, Google and 1Password return `fmt: "none"` with an all-zero AAGUID, because a per-model identifier is a tracking vector. passkify verifies any statement that arrives, never requires one, and defaults to `attestation: 'none'`. The full reasoning is in [the security guide](/docs/guides/security#attestation). ## Next - [Quickstart](/docs/quickstart) to build the thing. - [PasskeyServer](/docs/server/passkey-server) for the API surface. - [Security model](/docs/guides/security) for every check that runs. --- # Examples Complete runnable apps for Express and Next.js, with every file listed. Every file, in full. Nothing is elided and nothing links away: copy what is on this page and it runs. - [Express](#express) — One server file plus one HTML file. Sign-up, usernameless sign-in, autofill, and a passkey management list. - [Next.js App Router](#nextjs-app-router) — Catch-all route handler, shared server instance, signed-cookie session, and a client sign-in component. ## Express A complete app in two files. ### server.js ```js import express from 'express'; import session from 'express-session'; import { PasskeyServer, MemoryStore } from 'passkify'; const PORT = 3000; const ORIGIN = `http://localhost:${PORT}`; const app = express(); app.use(express.json()); app.use( session({ secret: process.env.SESSION_SECRET ?? 'dev-only-secret', resave: false, saveUninitialized: false, cookie: { httpOnly: true, sameSite: 'lax' /* secure: true in production */ }, }), ); const passkeys = new PasskeyServer({ rpName: 'Passkify Demo', origin: ORIGIN, // scheme + host + port, exactly as the browser sees it store: new MemoryStore(), // development only hooks: { onRegistered: ({ user, isNewUser }) => console.log(isNewUser ? `signed up: ${user.username}` : `added a passkey: ${user.username}`), onAuthenticated: ({ user }) => console.log(`signed in: ${user.username}`), }, }); app.use( passkeys.express({ // Identifies the signed-in visitor. Enables adding a second passkey and // unlocks the credential-management routes. getSessionUserId: (req) => req.session.userId ?? null, onRegister: (req, _res, { user }) => { req.session.userId = user.id; req.session.username = user.username; }, onLogin: (req, _res, { user }) => { req.session.userId = user.id; req.session.username = user.username; }, }), ); app.get('/api/me', (req, res) => { if (!req.session.userId) return res.status(401).json({ error: 'not signed in' }); res.json({ id: req.session.userId, username: req.session.username }); }); app.post('/api/signout', (req, res) => { req.session.destroy(() => res.json({ ok: true })); }); app.use(express.static('public')); app.listen(PORT, () => console.log(ORIGIN)); ``` ### public/index.html ```html
``` > **About that import path** > > A real app writes `import { register } from 'passkify/client'` and lets its bundler resolve it. This demo has no bundler, so the server exposes the package build as a static directory: ```js app.use('/passkify', express.static('node_modules/passkify/dist/esm')); ``` ### Running it ```bash npm install passkify express express-session node server.js # open http://localhost:3000 ``` `localhost` counts as a secure context, so this works over plain HTTP. See [testing on a phone](/docs/guides/troubleshooting#testing-on-a-phone) for anything else. ## Next.js App Router Four files. ### lib/passkeys.ts ```ts import { PasskeyServer } from 'passkify'; import { store } from './store'; if (!process.env.NEXT_PUBLIC_ORIGIN) { throw new Error('NEXT_PUBLIC_ORIGIN is required, for example https://acme.com'); } /** * One instance, shared across requests. * * In development Next re-evaluates modules on every edit, so stash it on * `globalThis`. Otherwise each reload gets a fresh store and every in-flight * challenge disappears. */ const globalForPasskeys = globalThis as unknown as { passkeys?: PasskeyServer }; export const passkeys = globalForPasskeys.passkeys ?? new PasskeyServer({ rpName: 'Acme', origin: process.env.NEXT_PUBLIC_ORIGIN, store, }); if (process.env.NODE_ENV !== 'production') { globalForPasskeys.passkeys = passkeys; } ``` ### lib/session.ts ```ts import { createHmac, timingSafeEqual } from 'node:crypto'; export const SESSION_COOKIE = 'session'; const SECRET = process.env.SESSION_SECRET; if (!SECRET) throw new Error('SESSION_SECRET is required'); const sign = (value: string): string => createHmac('sha256', SECRET).update(value).digest('base64url'); export function createSession(userId: string): string { const value = `${userId}.${sign(userId)}`; return [ `${SESSION_COOKIE}=${encodeURIComponent(value)}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', process.env.NODE_ENV === 'production' ? 'Secure' : '', 'Max-Age=2592000', ].filter(Boolean).join('; '); } export function readSession(request: Request): string | null { const header = request.headers.get('cookie'); if (!header) return null; const match = new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([^;]+)`).exec(header); if (!match) return null; const [userId, signature] = decodeURIComponent(match[1]).split('.'); if (!userId || !signature) return null; const expected = Buffer.from(sign(userId)); const provided = Buffer.from(signature); if (expected.length !== provided.length || !timingSafeEqual(expected, provided)) return null; return userId; } ``` > **This is a minimal session on purpose** > > It keeps the passkey wiring legible. Use iron-session, Auth.js, Lucia or your own in a real app; passkify only needs to hand you a verified user. ### app/api/passkey/[...passkey]/route.ts ```ts import { passkeys } from '@/lib/passkeys'; import { createSession, readSession } from '@/lib/session'; const handler = passkeys.handler({ // Must match this file's location: app/api/passkey/[...passkey] serves // /api/passkey/*. basePath: '/api/passkey', getSessionUserId: (request) => readSession(request), // Returning a HeadersInit merges into the default JSON reply. onRegister: (_request, { user }) => ({ 'set-cookie': createSession(user.id) }), onLogin: (_request, { user }) => ({ 'set-cookie': createSession(user.id) }), }); export { handler as GET, handler as POST, handler as PATCH, handler as DELETE }; // Passkey ceremonies are stateful and must never be cached or prerendered. export const dynamic = 'force-dynamic'; export const runtime = 'nodejs'; // the server half needs node:crypto ``` ### app/signin/page.tsx ```tsx 'use client'; import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { register, login, signInWithAutofill, configure, isSupported, PasskeyError, } from 'passkify/client'; // Must match `basePath` in the route handler. configure({ baseUrl: '/api/passkey' }); export default function SignInPage() { const router = useRouter(); const [username, setUsername] = useState(''); const [status, setStatus] = useState(''); const [busy, setBusy] = useState(false); useEffect(() => { if (!isSupported()) return; const controller = new AbortController(); signInWithAutofill({ signal: controller.signal }) .then((result) => { if (result) router.push('/'); }) .catch(() => {}); // Aborting on unmount matters: a pending autofill request survives // otherwise and blocks the next ceremony. return () => controller.abort(); }, [router]); const attempt = (action: () => Promise) => async () => { setBusy(true); setStatus(''); try { await action(); router.push('/'); } catch (error) { if (error instanceof PasskeyError && error.isUserCancellation) setStatus(''); else setStatus(error instanceof Error ? error.message : 'Something went wrong.'); } finally { setBusy(false); } }; if (!isSupported()) return

This browser does not support passkeys.

; return (
setUsername(event.target.value)} autoComplete="username webauthn" placeholder="username" /> {status &&

{status}

}
); } ``` ### .env.local ```bash NEXT_PUBLIC_ORIGIN=http://localhost:3000 SESSION_SECRET=replace-with-32-or-more-random-bytes ``` > **MemoryStore does not work on serverless** > > A challenge issued by one instance is invisible to the next, so logins fail intermittently. Postgres for users and credentials plus Redis for challenges is a good split; see [store adapters](/docs/storage/adapters). ## Where to go next - [Replace the store](/docs/storage/adapters) — Postgres, Prisma, Drizzle, Redis and MongoDB implementations. - [Another framework](/docs/guides/frameworks) — Hono, SvelteKit, Remix, Bun, Deno and Cloudflare Workers. - [A settings page](/docs/server/credentials) — List, rename and revoke passkeys. - [Before you ship](/docs/guides/security) — The checklist, and what stays your responsibility. --- # PasskeyServer The one object you construct, and every method it exposes. The one object you construct. Everything on the server side hangs off it. ```js import { PasskeyServer, MemoryStore } from 'passkify'; const passkeys = new PasskeyServer({ rpName: 'Acme', origin: 'https://acme.com', store: new MemoryStore(), }); ``` Create it once, at module scope, and share it across requests. It holds no per-request state. > **In Next.js and other hot-reloading dev servers** > > Stash the instance on `globalThis` in development. Otherwise every file edit re-evaluates the module, and with `MemoryStore` that discards every in-flight challenge. The [examples page](/docs/examples#nextjs-app-router) shows the pattern. ## Constructor ### new PasskeyServer(config) ```ts new PasskeyServer(config: PasskeyServerConfig) ``` Validates the configuration eagerly and throws `PasskeyError('configuration_error')` if anything is wrong. Every option is documented on [configuration](/docs/server/configuration). > **Why configuration is validated at construction, not at first use** > > The two mistakes that account for almost every "passkeys don't work" report are an `rpID` that is not a registrable suffix of the page's origin, and an `origin` list missing the port the app is actually served on. Both are silent: they surface days later as an opaque `NotAllowedError` in somebody's browser with no stack trace pointing at the cause. Checking at construction turns both into a server that refuses to start with a message naming the fix. A crash on boot is a much cheaper failure than a login that mysteriously does not work. - `configuration_error` — `rpName` or `store` missing, an `origin` that is not a bare scheme-host-port URL, a non-HTTPS origin that is not localhost, an `rpID` that is not valid for the given origins, or a `challengeSize` below 16. ## Properties ### rpID ```ts readonly rpID: string ``` The Relying Party ID in force, after defaulting from `origin`. > **Why it works this way** > > Worth logging at boot. When a client reports `rpid_mismatch`, the first question is always "what does the server actually think the rpID is", and reading it back is faster than re-deriving it from config. ### store ```ts readonly store: PasskeyStore ``` The store the server was constructed with. Handy in tests and for admin tooling that needs to reach credentials directly. ## The four ceremony methods These are the whole API. Two to start a ceremony, two to finish one. ### startRegistration " > Begins registering a passkey. Send the returned `options` to the browser verbatim. Covered in full on [registration](/docs/server/registration). ```js // New account const { options } = await passkeys.startRegistration({ username: 'ada' }); // Signed-in user adding another device const { options } = await passkeys.startRegistration({ userId: session.userId }); ``` ### finishRegistration " > Verifies the browser's response and stores the credential. Throws on any failure. Full check list on [registration](/docs/server/registration). ### startAuthentication " > Begins a login. Call it with no arguments for the usernameless flow, which is what you want. See [authentication](/docs/server/authentication). ```js const { options } = await passkeys.startAuthentication(); ``` ### finishAuthentication " > Verifies an assertion. On success the returned `user` is authenticated and you should establish your session. See [authentication](/docs/server/authentication). > **Both finish methods throw rather than returning verified: false** > > There is no falsy result to accidentally treat as success. Wrap them in `try`/`catch` and branch on [`error.code`](/docs/errors). ## Credential management Covered on [credential management](/docs/server/credentials). ### listCredentials " /> ### renameCredential " /> ### deleteCredential " /> ## HTTP adapters Rather than writing the four routes yourself. Covered on [HTTP adapters](/docs/server/adapters). ### express Promise" > Express and Connect middleware. Also works on a bare `node:http` server, because it parses its own JSON body when none has been parsed already. ### handler Promise" > A standards `Request` to `Response` handler for Next.js App Router, Hono, Bun, Deno, Remix and Cloudflare Workers. > **Why two adapters and not a plugin system** > > These two shapes cover essentially every JavaScript server. Node-stream middleware is one; the fetch API is the other, and everything built in the last few years speaks it. A plugin architecture would be more surface area to maintain for frameworks that already fit one of these two. Both adapters are thin wrappers over the same internal dispatcher, so they cannot drift apart in behaviour. If neither fits, call the four ceremony methods directly. That is not a fallback; it is the actual API. ## Next - [Configuration](/docs/server/configuration) for every option. - [Registration](/docs/server/registration) and [authentication](/docs/server/authentication) in depth. --- # Configuration Every option on PasskeyServerConfig, what it changes, and when to move it. ```js const passkeys = new PasskeyServer({ rpName: 'Acme', origin: 'https://acme.com', store: myStore, }); ``` Three required options. The rest have defaults chosen so that the common case needs no configuration at all. ## Required ### rpName ```ts rpName: string ``` The site name shown in the operating system's passkey prompt. > **Keep it short** > > It appears mid-sentence in a system dialog: *"Do you want to save a passkey for Acme?"* A long legal entity name reads badly there and gets truncated on narrow phone dialogs. Use "Acme", not "Acme Corporation Customer Portal". ### origin ```ts origin: string | readonly OriginMatcher[] ``` The origin or origins your login page is served from, scheme and port included. ```js origin: 'https://acme.com' origin: ['https://acme.com', 'https://www.acme.com'] ``` Every response carries the origin the browser saw, and passkify rejects any that is not in this list. > **Why exact strings, and why a pattern is dangerous** > > This list is the second half of the anti-phishing guarantee. The browser enforces the `rpID` binding; this check enforces that the ceremony ran on a page you actually serve. A matcher may also be a `RegExp` or a predicate, for wildcard-subdomain setups. Anchor it if you use one. An unanchored pattern like `/https:\/\/.*acme\.com/` matches `https://acme.com.evil.net`, and that single missing anchor hands an attacker your users' credentials. > **Behind a proxy, use the public origin** > > The browser reports the origin it loaded the page from, which is your public URL, not `http://localhost:8080` where your app happens to listen. ### store ```ts store: PasskeyStore ``` Where users, credentials and challenges live. See [storage](/docs/storage). Use `new MemoryStore()` while you are trying things out; it is a complete, readable implementation you can use as a template for a real one. ## Identity ### rpID ```ts rpID?: string ``` The domain your passkeys are bound to. Must be the origin's host or a parent domain of it. ```js // Serving app.acme.com and www.acme.com from one account system: { origin: ['https://app.acme.com', 'https://www.acme.com'], rpID: 'acme.com' } ``` > **Decide this before your first user registers** > > Credentials are permanently bound to the `rpID` they were created under. Changing it later does not migrate anything: every existing passkey stops working and every user has to enrol again. If there is any chance you will want one passkey to work across subdomains, set it to your apex domain now. Going from `acme.com` to `app.acme.com` later is impossible without a mass re-enrolment; the reverse is equally impossible. ## Ceremony policy ### userVerification ```ts userVerification?: 'required' | 'preferred' | 'discouraged' ``` Whether the authenticator must verify *who* the user is (PIN, fingerprint, face) rather than only that someone is present. - **'required'** (`strictest`, required) — The response is rejected unless the User Verified flag is set. Makes a passkey two factors in one gesture. - **'preferred'** (`default`) — Asks for verification, accepts a response without it. - **'discouraged'** (`loosest`) — Only presence. Faster, and appropriate for a second factor alongside a password. > **Why 'preferred' rather than 'required'** > > `'required'` is the correct choice when a passkey is your only factor, and you should use it there. It is not the default because it turns some older security keys, and some authenticators with no PIN configured, into a dead end: the ceremony fails and the user has no way to proceed. `'preferred'` gets verification everywhere it is available, which in practice is nearly everywhere, without locking anyone out. Move to `'required'` deliberately, once you know your users' authenticators can satisfy it. ### residentKey ```ts residentKey?: 'required' | 'preferred' | 'discouraged' ``` Whether the credential is discoverable, meaning it can be used to sign in without typing a username first. > **Why it works this way** > > `'required'` guarantees usernameless login but can fail outright on authenticators with limited credential storage, which includes many hardware security keys. `'preferred'` produces a discoverable credential wherever possible and falls back silently where not, so `login()` works for the vast majority while nobody is turned away. ### authenticatorAttachment ```ts authenticatorAttachment?: 'platform' | 'cross-platform' ``` Restrict which kind of authenticator may be used. - **'platform'** (`built in`) — Touch ID, Face ID, Windows Hello, Android screen lock. Bound to this device. - **'cross-platform'** (`roaming`) — USB security keys, and phones used over the hybrid transport. > **Why the default is unset** > > Leaving it unset lets the browser offer everything the user has, including scanning a QR code to use a phone. Setting `'platform'` removes that option, so a visitor on a shared or work computer has no way to sign in at all. Set it only when you have a policy reason, not to simplify the prompt. ## Timing ### timeout ```ts timeout?: number ``` How long the browser prompt stays open, in milliseconds. Passed to the authenticator as a hint. > **Why it works this way** > > Sixty seconds is enough to notice a prompt and touch a sensor. It is not enough to find a phone in another room, so raise it toward 120000 if your flow expects a cross-device hybrid scan. ### challengeTimeout ```ts challengeTimeout?: number ``` How long a challenge remains valid server-side, in milliseconds. > **Why it is longer than the browser timeout** > > The browser prompt and the server's patience are different clocks. A user might dismiss the prompt, find their security key, and click your button again; a tight server window would reject that second attempt with a confusing `challenge_not_found`. Five minutes covers realistic hesitation without leaving a useful replay surface, especially since the challenge is single-use regardless. ## Cryptography ### challengeSize ```ts challengeSize?: number ``` Challenge length in bytes. The specification's floor is 16, and passkify refuses anything smaller. > **Why it works this way** > > Sixteen bytes is already beyond guessing. Thirty-two costs nothing, matches SHA-256's output width, and removes any need to think about it again. ### supportedAlgorithms ```ts supportedAlgorithms?: readonly number[] ``` COSE algorithm identifiers offered to authenticators, best first. The defaults are ES256 and RS256. ```js import { COSEAlgorithm } from 'passkify'; supportedAlgorithms: [COSEAlgorithm.ES256, COSEAlgorithm.RS256, COSEAlgorithm.EdDSA] ``` > **Why the list is deliberately short** > > ES256 covers essentially every shipping passkey provider. RS256 is there for older Windows Hello and TPM-backed keys. Both are mandatory-to-implement in the WebAuthn specification, so the pair is universal. A shorter list means fewer verification code paths that have to be right. passkify can verify ES384, ES512, PS256/384/512, RS384, RS512, RS1 and EdDSA, and you can enable any of them here, but offering algorithms nobody uses adds risk without adding reach. ## Attestation ### attestation ```ts attestation?: 'none' | 'indirect' | 'direct' | 'enterprise' ``` How much the authenticator should say about itself. > **Leave this alone** > > Requesting attestation shows an additional, more alarming consent prompt on some platforms, and consumer passkey providers return an anonymous statement anyway. You pay a conversion cost for information you do not get. Change it only if you are an enterprise pinning specific authenticator models, and then also set `attestationRootCertificates`. See [attestation](/docs/guides/security#attestation). ### attestationRootCertificates ```ts attestationRootCertificates?: readonly (string | Uint8Array)[] ``` PEM or DER root certificates to validate attestation chains against. > **Why it works this way** > > Without roots, `AttestationResult.trusted` is always `false`, and honestly so: a chain that validates against nothing proves nothing. passkify does not ship a FIDO Metadata Service blob, because MDS is a live, signed, rotating document and an embedded stale copy would be a guarantee the library cannot keep. ### requireBackupEligible ```ts requireBackupEligible?: boolean ``` Reject credentials that cannot sync between devices. > **Why off by default** > > Turning this on locks out every hardware security key, because they are device-bound by design. It makes sense only if your recovery story depends on credentials surviving a lost phone, and you would rather refuse enrolment than support a user with one device-bound passkey. A softer approach: leave it off, record `deviceType` (passkify always does), and prompt users whose only passkey is `singleDevice` to add a second one. ## Hooks ### hooks ```ts hooks?: PasskeyHooks ``` Callbacks fired after a successful ceremony. Throwing from a hook fails the ceremony. ### hooks.onRegistered void | Promise" kind="hook"> A credential was verified and stored. Good place for a welcome email or an audit record. ### hooks.onAuthenticated void | Promise" kind="hook"> A login succeeded. > **This hook cannot set a cookie** > > It receives no request or response object. Session establishment belongs in the [HTTP adapter's](/docs/server/adapters) `onLogin`, which does. Use this hook for side effects that are not request-scoped. ### hooks.onCounterRegression boolean | Promise" kind="hook"> A credential's signature counter went backwards, which can mean the authenticator has been cloned. Return `true` to allow the login anyway. ```js onCounterRegression: ({ user, storedCounter, presentedCounter }) => { logger.warn('possible cloned authenticator', { userId: user.id, storedCounter, presentedCounter, }); return false; // reject, the default }, ``` > **Why this is a hook rather than a boolean setting** > > Counter regression is a signal, not a verdict. It genuinely does indicate cloning; it also fires for buggy authenticator firmware and for credentials restored from a backup. Which of those you are looking at depends on your user base, and the right response is usually to log and alert rather than silently allow or silently deny. A boolean could only express "reject" or "ignore". A hook lets you record the event, notify the user, and then decide. ## Full example ```js import { PasskeyServer, COSEAlgorithm } from 'passkify'; import { store } from './store.js'; export const passkeys = new PasskeyServer({ rpName: 'Acme', origin: ['https://acme.com', 'https://www.acme.com'], rpID: 'acme.com', store, userVerification: 'required', residentKey: 'preferred', timeout: 120_000, challengeTimeout: 300_000, supportedAlgorithms: [COSEAlgorithm.ES256, COSEAlgorithm.RS256], hooks: { onRegistered: ({ user, isNewUser }) => audit('passkey.registered', user.id, { isNewUser }), onAuthenticated: ({ user }) => audit('passkey.login', user.id), onCounterRegression: ({ user }) => { audit('passkey.counter_regression', user.id); return false; }, }, }); ``` --- # Registration startRegistration and finishRegistration, and the eighteen checks in between. Creating a passkey. Two calls: one to issue options, one to verify what comes back. ## startRegistration ### startRegistration " > Issues creation options for the browser. Send `result.options` across untouched; the client library converts it to the WebAuthn shape at the last moment. - **username** (`string`) — The username for a **new** account. Mutually exclusive with `userId`. - **displayName** (`string`, default `username`) — Shown in the operating system's passkey picker. This is the label a user sees months later when choosing between saved passkeys, so use something human. - **userId** (`string`) — An existing account's user handle, taken from the caller's **authenticated session**, to let a signed-in user add another device. - **userVerification** (`'required' | 'preferred' | 'discouraged'`, default `config value`, required) — Override the configured policy for this one ceremony. Recorded with the challenge, so `finishRegistration` enforces the same policy the ceremony was started under. **Returns** `{ options: RegistrationOptionsJSON; userId: string; isNewUser: boolean }` — `userId` is the handle these options were issued for. `isNewUser` is `true` when a successful ceremony will create an account. - `credential_exists` — The username is already taken. See below. - `unknown_user` — `userId` was given but no such account exists. - `malformed_response` — Neither `username` nor `userId` was supplied. - `configuration_error` — The account's ID exceeds 64 bytes when UTF-8 encoded, which WebAuthn does not allow for a user handle. ### Two ways to call it, and why they are separate **Do this — New account: by username:** ```js const { options } = await passkeys.startRegistration({ username: 'ada', }); ``` Throws `credential_exists` if `ada` already exists. **Do this — Existing account: by session:** ```js const { options } = await passkeys.startRegistration({ userId: req.session.userId, // never from the body }); ``` Excludes credentials already on the account. > **Never let an unauthenticated request name the account** > > If a request body can say which existing account a new passkey attaches to, then anyone who knows a username owns that account: they register their own passkey against it and sign in as its owner. This is the single most common way to get a passkey integration catastrophically wrong. passkify makes it hard to express: the `username` form refuses existing accounts outright, and the [mounted routes](/docs/server/adapters) ignore a username in the body whenever a session is present. > **Why the account is not created until the ceremony finishes** > > `startRegistration({ username })` does not touch your users table. It mints a user handle, records it with the challenge, and returns. If it created the row up front, every abandoned prompt, and users abandon plenty, would leave a half-registered account with no credential attached. Those accounts are worse than useless: they occupy the username, so the user who closed the dialog by accident can never sign up with it again. The account is created inside `finishRegistration`, after every check passes. ### What the options contain ```js { rp: { id: 'acme.com', name: 'Acme' }, user: { id: '', name: 'ada', displayName: 'Ada Lovelace' }, challenge: '<43 base64url characters>', pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }], timeout: 60000, excludeCredentials: [], authenticatorSelection: { residentKey: 'preferred', requireResidentKey: false, userVerification: 'preferred', }, attestation: 'none', } ``` > **Why excludeCredentials matters** > > It lists the credentials the account already has, so the authenticator can refuse to create a duplicate. Without it, a user who taps "register" twice ends up with two passkeys on the same device, and the picker at login shows two identical-looking entries. When the authenticator declines, the browser raises `InvalidStateError`, which `passkify/client` surfaces as [`already_registered`](/docs/errors#already_registered). Treat that as information, not an error: show "this device already has a passkey for your account". ## finishRegistration ### finishRegistration " > Verifies the browser's response, creates the account if needed, and stores the credential. **Returns** `{ verified: true; user; credential; isNewUser; attestation }` — Always `verified: true`. Any failure throws instead. ```js const result = await passkeys.finishRegistration(responseFromBrowser); result.user; // { id, username, displayName } result.credential.id; // base64url credential ID result.credential.deviceType; // 'multiDevice' | 'singleDevice' result.isNewUser; // true if this ceremony created the account result.attestation.format; // 'none' for most consumer passkeys ``` ### The checks Each one has a test that tampers with exactly that field and asserts the matching code. 18 checks, in WebAuthn §7.1 order. | # | Check | Failure code | Specification | | --- | --- | --- | --- | | 1 | The response has the fields it must have | `malformed_response` | WebAuthn §7.1 step 1 | | 2 | The challenge is one we issued and have not yet consumed | `challenge_not_found` | WebAuthn §7.1 step 8 | | 3 | The challenge was issued for a registration, not a login | `type_mismatch` | WebAuthn §7.1 step 8 | | 4 | The signed challenge equals the stored one | `challenge_mismatch` | WebAuthn §7.1 step 8 | | 5 | clientData.type is exactly "webauthn.create" | `type_mismatch` | WebAuthn §7.1 step 7 | | 6 | clientData.origin is in the allow-list | `origin_mismatch` | WebAuthn §7.1 step 9 | | 7 | The ceremony did not run in a cross-origin frame | `origin_mismatch` | WebAuthn §7.1 step 10 | | 8 | The attestation object has fmt, attStmt and authData | `parse_error` | WebAuthn §7.1 step 12 | | 9 | The RP ID hash matches this relying party | `rpid_mismatch` | WebAuthn §7.1 step 13 | | 10 | The user-present flag is set | `user_not_present` | WebAuthn §7.1 step 14 | | 11 | The user was verified, when the ceremony required it | `user_not_verified` | WebAuthn §7.1 step 15 | | 12 | The backup-state flag is not set without backup-eligible | `parse_error` | WebAuthn §6.1.3 | | 13 | The credential is backup-eligible, when the site requires it | `attestation_failed` | passkify policy | | 14 | The response carries attested credential data | `parse_error` | WebAuthn §7.1 step 12 | | 15 | rawId equals the credential ID inside the authenticator data | `parse_error` | WebAuthn §7.1 step 12 | | 16 | The public key uses an algorithm we offered | `unsupported_algorithm` | WebAuthn §7.1 step 16 | | 17 | The attestation statement, if present, is internally consistent | `attestation_failed` | WebAuthn §7.1 step 19 | | 18 | The credential is not already registered, to anyone | `credential_exists` | WebAuthn §7.1 step 22 | > **Why the last check spans all users, not just this one** > > A credential ID is globally unique by construction. If one arrives that is already in your database under a different account, something is wrong that a per-user check would miss entirely: a replayed registration, a store bug, or a deliberate attempt to bind one authenticator to two accounts. Scoping the uniqueness check to the current user would let that through silently, and the second account would then be signed into by whoever holds the first credential. ### Handling the result ```js app.post('/passkey/register/finish', async (req, res) => { try { const { user, isNewUser } = await passkeys.finishRegistration(req.body); req.session.userId = user.id; // log them straight in res.json({ ok: true, isNewUser }); } catch (error) { if (error instanceof PasskeyError) { return res.status(error.status).json(error.toJSON()); } throw error; } }); ``` Or skip the route entirely and use an [adapter](/docs/server/adapters). ## Adding a second passkey Worth prompting for at signup. A user with one device-bound passkey is one lost phone away from losing the account. ```js // Route protected by your existing session middleware. app.post('/settings/passkeys/start', requireAuth, async (req, res) => { const { options } = await passkeys.startRegistration({ userId: req.session.userId, }); res.json(options); }); ``` ```js // Browser, already signed in. import { register } from 'passkify/client'; await register(); // no username: adds to the current account ``` ## Next - [Authentication](/docs/server/authentication) for the login side. - [Credential management](/docs/server/credentials) to list and revoke. - [Errors](/docs/errors) for every code above. --- # Authentication startAuthentication and finishAuthentication, usernameless and username-first. Signing in. Two calls, and two flows that differ only in whether you name the account up front. ## startAuthentication ### startAuthentication " > Issues request options. Call it with no arguments for the usernameless flow. - **username** (`string`) — Scope the ceremony to one account. Usually unnecessary, see below. - **userId** (`string`) — Same, by user handle, when you already know who is signing in. Useful for re-authentication before a sensitive action. - **userVerification** (`'required' | 'preferred' | 'discouraged'`, default `config value`, required) — Override for this ceremony. Recorded with the challenge and enforced at verification, so a stricter policy cannot be dropped between the two calls. **Returns** `{ options: AuthenticationOptionsJSON; userId?: string }` — `userId` is present only when the ceremony was scoped to a known account. ### The two flows **Do this — Usernameless: prefer this:** ```js const { options } = await passkeys.startAuthentication(); ``` `allowCredentials` is omitted. The browser lists every passkey for your domain and shows a picker. The account is resolved from the user handle in the response. **Not this — Username-first: only if you must:** ```js const { options } = await passkeys.startAuthentication({ username: 'ada', }); ``` `allowCredentials` names that account's credentials. Needed for authenticators that cannot store discoverable credentials. > **Why usernameless is the recommended path** > > It is the entire point of passkeys. The browser already knows which credentials exist for your domain, so asking for a username first adds a screen, a text field and a failure mode without buying anything. It is also better for security: with no `allowCredentials`, the response reveals nothing about which accounts exist. See the enumeration note below. > **An unknown username does not produce an error** > > `startAuthentication({ username: 'nobody' })` returns a normal, well-formed challenge with no `allowCredentials`. Answering honestly would turn your login form into a free account-enumeration oracle: anyone could learn which usernames are registered by watching for a 404. The ceremony fails later at verification, exactly like any other bad attempt. This means a typo'd username produces `unknown_credential` at the *finish* step, not a helpful message at the start. That trade is deliberate. ## finishAuthentication ### finishAuthentication " > Verifies the assertion. On success the returned `user` is authenticated. **Returns** `{ verified: true; user; credential; signCount; userVerified }` — `userVerified` reports whether the authenticator checked a PIN or biometric, as opposed to mere presence. Useful when you allow `'preferred'` but want to demand verification before a sensitive action. ```js const { user, userVerified } = await passkeys.finishAuthentication(req.body); req.session.userId = user.id; req.session.strongAuth = userVerified; ``` ### The checks 15 checks, in WebAuthn §7.2 order. | # | Check | Failure code | Specification | | --- | --- | --- | --- | | 1 | The response has the fields it must have | `malformed_response` | WebAuthn §7.2 step 1 | | 2 | The challenge is one we issued and have not yet consumed | `challenge_not_found` | WebAuthn §7.2 step 11 | | 3 | The challenge was issued for a login, not a registration | `type_mismatch` | WebAuthn §7.2 step 11 | | 4 | The signed challenge equals the stored one | `challenge_mismatch` | WebAuthn §7.2 step 11 | | 5 | clientData.type is exactly "webauthn.get" | `type_mismatch` | WebAuthn §7.2 step 10 | | 6 | clientData.origin is in the allow-list | `origin_mismatch` | WebAuthn §7.2 step 12 | | 7 | The ceremony did not run in a cross-origin frame | `origin_mismatch` | WebAuthn §7.2 step 13 | | 8 | The credential ID is one we have stored | `unknown_credential` | WebAuthn §7.2 step 5 | | 9 | The credential belongs to the account being signed into | `unknown_credential` | WebAuthn §7.2 step 6 | | 10 | The user handle, when present, names the credential owner | `unknown_credential` | WebAuthn §7.2 step 6 | | 11 | The RP ID hash matches this relying party | `rpid_mismatch` | WebAuthn §7.2 step 15 | | 12 | The user-present flag is set | `user_not_present` | WebAuthn §7.2 step 16 | | 13 | The user was verified, when the ceremony required it | `user_not_verified` | WebAuthn §7.2 step 17 | | 14 | The signature verifies against the stored public key | `bad_signature` | WebAuthn §7.2 step 20 | | 15 | The signature counter has not gone backwards | `counter_regression` | WebAuthn §7.2 step 21 | > **Why the account check and the user-handle check both exist** > > They defend different flows. The account check covers username-first: the ceremony was started for Ada, so a credential belonging to Bob must not satisfy it, even though Bob's credential is perfectly valid and his signature verifies. The user-handle check covers usernameless: there is no scoped account, so the user handle in the response is what tells you who signed in. It has to agree with the credential's stored owner, or an attacker could present a valid signature from their own passkey alongside somebody else's handle. Dropping either one leaves a hole in the flow the other does not cover. ### The signature check The authenticator signs `authenticatorData` concatenated with `SHA-256(clientDataJSON)`. passkify rebuilds that byte string, loads the stored COSE public key, and verifies. > **Why the stored key is raw COSE bytes rather than PEM** > > The credential record keeps `publicKey` as the exact CBOR the authenticator produced. It is re-parsed on every login into a Node `KeyObject` through a JWK import, which puts curve and point validation inside OpenSSL rather than in library code. Storing a converted format would mean the conversion happened once, at registration, with no way to revisit it if a bug were found. Keeping the original means the parse is re-run and re-validated on every use, and a fix applies retroactively to credentials already in your database. ## The signature counter Some authenticators increment a counter on every signature. If one arrives lower than the stored value, the same credential has signed twice from different states, which is what a cloned authenticator looks like. ```js hooks: { onCounterRegression: ({ user, storedCounter, presentedCounter }) => { logger.warn('counter went backwards', { userId: user.id, storedCounter, presentedCounter }); return false; // reject }, } ``` > **Most passkeys report zero forever** > > Synced credentials cannot maintain a coherent counter across devices, so they do not try. passkify skips the comparison when both the stored and presented counters are zero; otherwise every second login would fail. Treat the counter as a detection signal, not a defence. An attacker holding the private key can report whatever counter they like. ## Re-authentication For a sensitive action, run a second ceremony scoped to the signed-in account: ```js app.post('/settings/danger/start', requireAuth, async (req, res) => { const { options } = await passkeys.startAuthentication({ userId: req.session.userId, userVerification: 'required', // demand a biometric this time }); res.json(options); }); ``` Because the requirement is recorded with the challenge, `finishAuthentication` enforces `'required'` for this ceremony even though the server default is `'preferred'`. ## Next - [Credential management](/docs/server/credentials) - [HTTP adapters](/docs/server/adapters) to skip writing these routes - [Security model](/docs/guides/security) for the reasoning behind each check --- # Credential management Listing, renaming and revoking passkeys from an account settings page. Everything an account settings page needs. All three methods take `userId` first, and check it. > **userId must come from your session** > > Every method here verifies that the credential belongs to the account you named. Without that, knowing a credential ID would be enough to rename or delete someone else's passkey, and credential IDs travel in plain sight on every login response. ## Methods ### listCredentials " > Every passkey on an account, shaped for display. **Returns** `PublicCredentialInfo[]` — Sorted oldest first. ```js const passkeysForUser = await passkeys.listCredentials(req.session.userId); ``` ```js [ { id: 'kruh1zrhU4HRYwXpxyCsG6tqknL6sIAC81dnWTtYj18', nickname: 'MacBook Pro', deviceType: 'multiDevice', backedUp: true, transports: ['internal', 'hybrid'], aaguid: '00000000-0000-0000-0000-000000000000', createdAt: new Date('2026-02-11T09:14:22.000Z'), lastUsedAt: new Date('2026-08-27T05:41:14.523Z'), }, ] ``` > **Why this is a separate shape from PasskeyCredential** > > The stored record also holds `userId` and `publicKey`. Neither belongs in a response to the browser: the public key is not secret but it is noise, and echoing the user handle back into page markup makes it that much easier to harvest. `PublicCredentialInfo` is the subset that is safe to render, so the safe thing is also the easy thing. ### renameCredential " > Give a passkey a human label. Truncated to 128 characters. ```js await passkeys.renameCredential(req.session.userId, credentialId, 'Work laptop'); ``` > **Why nicknames are worth offering** > > Authenticators do not tell you what they are. Most consumer passkeys report an all-zero AAGUID on purpose, so a settings page can only ever say "passkey, created in February". With three of them listed, a user cannot tell which one to revoke after losing a laptop, and the safe-looking move is to revoke all of them. Prompting for a nickname right after registration, while the user still knows which device they are holding, avoids that. - `unknown_credential` — No such credential, or it belongs to a different account. ### deleteCredential " > Revoke a passkey. ```js await passkeys.deleteCredential(req.session.userId, credentialId); ``` > **Why deleting the last passkey is refused** > > If passkeys are the only way into an account, removing the last one locks the user out permanently, with one click, from a settings screen where the consequence is not obvious. passkify throws `configuration_error` rather than allowing it. If your app also supports passwords or another factor, and being left with zero passkeys is fine, call `store.deleteCredential(id)` directly; the guard lives in the server method, not the store. - `unknown_credential` — No such credential, or it belongs to a different account. - `last_credential` — This is the account's only passkey. A 409, not a fault: surface it in the UI. ## A settings page Server routes, using your existing auth middleware: ```js app.get('/settings/passkeys', requireAuth, async (req, res) => { res.json(await passkeys.listCredentials(req.session.userId)); }); app.patch('/settings/passkeys/:id', requireAuth, async (req, res) => { await passkeys.renameCredential(req.session.userId, req.params.id, req.body.nickname); res.json({ ok: true }); }); app.delete('/settings/passkeys/:id', requireAuth, async (req, res) => { await passkeys.deleteCredential(req.session.userId, req.params.id); res.json({ ok: true }); }); ``` Or mount the [adapter](/docs/server/adapters) with `getSessionUserId` and get the same three routes for free under `/passkey/credentials`. ## What to surface to the user - **deviceType** (`'multiDevice' | 'singleDevice'`) — `multiDevice` means the credential syncs and survives losing this device. `singleDevice` does not. Say it in plain language: "Synced" versus "This device only". - **backedUp** (`boolean`) — Whether it is currently backed up. A `multiDevice` credential that is not yet `backedUp` means the user has not finished setting up their keychain, and they are one lost phone from being locked out. - **lastUsedAt** (`Date | undefined`) — Lets a user recognise which entry is the laptop they still have. `undefined` means it has never been used to sign in. - **createdAt** (`Date`) — The other half of the same question. Two passkeys created minutes apart are probably the same device registered twice. - **aaguid** (`string`) — The authenticator model UUID, all zeroes for most consumer passkeys. Do not build UI that depends on it being meaningful. > **Prompt when there is only one** > > The cheapest thing you can do for account recovery is notice `listCredentials(userId).length === 1` and ask the user to add a second passkey on a different device. It costs one banner and removes the most common way people lose access. --- # HTTP adapters The Express middleware, the fetch handler, and the routes they mount. Rather than writing four routes by hand, mount an adapter. Both expose the same endpoints and share the same internal dispatcher, so they cannot behave differently. ## Routes Relative to `basePath`, which defaults to `/passkey`. | Method | Path | Body | Session | | --- | --- | --- | --- | | `POST` | `/register/start` | `{ username?, displayName? }` | no | | `POST` | `/register/finish` | the browser's response | no | | `POST` | `/login/start` | `{ username? }` | no | | `POST` | `/login/finish` | the browser's response | no | | `GET` | `/credentials` | | **yes** | | `PATCH` | `/credentials/:id` | `{ nickname }` | **yes** | | `DELETE` | `/credentials/:id` | | **yes** | Responses are JSON with `cache-control: no-store`. > **Why no-store on every response** > > These endpoints are a challenge and response protocol. A cached `/login/start` would hand the same challenge to two different visitors, and a cached `/login/finish` would replay a successful verification. Neither is something an intermediary should ever be free to do. > **A session overrides the request body on register/start** > > When `getSessionUserId` returns an ID, `POST /register/start` ignores any `username` in the body and registers against the session's account. Trusting the body there would let a signed-in visitor attach their passkey to somebody else's account by naming it. See [registration](/docs/server/registration). ## Express ### express Promise" > ```js app.use(express.json()); app.use(passkeys.express({ basePath: '/passkey', getSessionUserId: (req) => req.session.userId ?? null, onLogin: (req, res, { user }) => { req.session.userId = user.id; }, onRegister: (req, res, { user }) => { req.session.userId = user.id; }, })); ``` - **basePath** (`string`, default `'/passkey'`) — Where the routes live. Must match the client's `baseUrl`. - **getSessionUserId** (``) — string | null | Promise"> Resolves the signed-in account. Supplying it unlocks two things: signed-in users can add another passkey, and the `/credentials` routes stop answering 401. - **onRegister** (``) — void | Promise"> After a successful registration. Set a session here if you sign people in on signup. - **onLogin** (``) — void | Promise"> After a successful login. This is where your session cookie gets set. > **Why session hooks live on the adapter, not on config.hooks** > > `hooks.onAuthenticated` receives the user and the credential and nothing else, because it is not request-scoped. Setting a cookie needs the response object. Keeping the two separate means the adapter hook has exactly what it needs for session work, and the config hook stays usable from code paths that have no HTTP request at all, such as a CLI or a background job. ### Behaviour worth knowing - **Unmatched paths call next()** (`pass through`) — Safe to mount at the top of the stack; it only claims paths under `basePath`. - **It parses its own body** (`no body-parser needed`) — If `req.body` is already set, it is used. Otherwise the adapter reads and parses the stream itself, capped at 512 kB, so the middleware also works on a bare `node:http` server. - **5xx failures go to next(error)** (`error handling`) — Client errors are answered as JSON. A genuine fault is forwarded to your error handler, so it reaches your logs instead of disappearing into a response body. ## Fetch ### handler Promise" > For Next.js App Router, Hono, Bun, Deno, Remix and Cloudflare Workers. ```ts // app/api/passkey/[...passkey]/route.ts const handler = passkeys.handler({ basePath: '/api/passkey', getSessionUserId: async (request) => readSession(request), onLogin: async (_request, { user }) => ({ 'set-cookie': await createSession(user.id), }), }); export { handler as GET, handler as POST, handler as PATCH, handler as DELETE }; ``` - **basePath** (`string`, default `'/passkey'`) — Must match where you mounted the handler. A mismatch returns a 404 whose message says exactly this, rather than a bare "not found". - **getSessionUserId** (``) — string | null | Promise"> Same role as the Express version, reading from the standard `Request`. - **onRegister** (``) — void | Response | HeadersInit | Promise<...>"> Return a `Response` to take over the reply entirely, or a `HeadersInit` to merge into the default JSON reply. - **onLogin** (``) — void | Response | HeadersInit | Promise<...>"> Same contract. > **Why the hooks can return either a Response or headers** > > There is no mutable response object in the fetch model, so the hook has to be able to influence the reply through its return value. Most of the time you only want to add a `Set-Cookie` and keep the standard JSON body, so returning a `HeadersInit` covers that with one line. Occasionally you want a redirect instead of JSON, which needs the whole `Response`. Supporting both keeps the common case short without blocking the uncommon one. ### Framework mounting ```ts // Hono app.all('/passkey/*', (c) => handler(c.req.raw)); // Bun Bun.serve({ fetch: handler }); // Remix export const action = ({ request }) => handler(request); // SvelteKit, src/routes/passkey/[...path]/+server.ts export const GET = ({ request }) => handler(request); export const POST = ({ request }) => handler(request); ``` > **Node runtime only** > > The server half uses `node:crypto` and does not run on an edge runtime. In Next.js, `export const runtime = 'nodejs'`. The browser half has no such constraint. ## Request and response shapes Both `finish` routes accept the ceremony response either bare or wrapped: ```js // Both work. fetch('/passkey/login/finish', { method: 'POST', body: JSON.stringify(assertion) }); fetch('/passkey/login/finish', { method: 'POST', body: JSON.stringify({ response: assertion }) }); ``` > **Why it works this way** > > They are told apart by `rawId`, which is present at the top level of a real ceremony response and only there. Both shapes are natural to post, and telling somebody they picked the wrong one is a poor use of a debugging session. Success: ```js { verified: true, user: { id, username, displayName }, credentialId, isNewUser } ``` Failure, with the status from [`PasskeyError.status`](/docs/errors): ```js { error: 'origin_mismatch', message: 'origin "https://evil.example.net" is not in the allowed list' } ``` ## Writing the routes yourself The adapters are convenience. The four methods are the API. ```js app.post('/auth/passkey/login/begin', async (req, res) => { const { options } = await passkeys.startAuthentication(); res.json(options); }); app.post('/auth/passkey/login/complete', async (req, res) => { const { user } = await passkeys.finishAuthentication(req.body); req.session.userId = user.id; res.json({ ok: true }); }); ``` Point the client at them with `configure({ baseUrl })`, or use the [lower-level ceremony calls](/docs/client#createcredential) if your paths do not follow the `/register/start` convention. --- # Client API register, login, autofill, capability detection, and the low-level ceremony calls. ```js import { register, login } from 'passkify/client'; ``` Roughly 6.5 kB gzipped, no dependencies, no Node built-ins. Runs in any bundler or straight from a `