# 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
}
);
}
```
### .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 `