@passkeykit/server
v3.1.2
Published
Server-side WebAuthn passkey verification — stateless or stateful, pure JS, works on serverless
Maintainers
Readme
@passkeykit/server
Server-side WebAuthn passkey verification — stateless by default. Works on Vercel, Cloudflare Workers, and traditional servers. Zero native dependencies.
Handles challenge generation, attestation/assertion verification, and includes scrypt password hashing (pure JS). Optional argon2 support via subpath export.
Install
npm install @passkeykit/server @simplewebauthn/server
@simplewebauthn/serveris a peer dependency — you control the version. This keeps the package itself lightweight while giving you full WebAuthn verification.Password-only? If you only need
hashPassword/verifyPassword, import from the subpath — no WebAuthn dependency required:npm install @passkeykit/serverimport { hashPassword, verifyPassword } from '@passkeykit/server/password';
Quick Start
Stateless (Serverless / Vercel / Cloudflare)
No database needed for challenges — they're encrypted into signed tokens.
import { PasskeyServer, FileCredentialStore } from '@passkeykit/server';
import { createExpressRoutes } from '@passkeykit/server/express';
const server = new PasskeyServer({
rpName: 'My App',
rpId: 'myapp.example.com',
allowedOrigins: ['https://myapp.example.com'],
encryptionKey: process.env.PASSKEY_SECRET!, // 32+ char secret
credentialStore: new FileCredentialStore('./data/credentials.json'),
});
// Mount ready-made Express routes
app.use('/api/auth/passkey', createExpressRoutes(server, {
getUserInfo: async (userId) => {
const user = await db.getUser(userId);
return user ? { id: user.id, name: user.name } : null;
},
onAuthenticationSuccess: async (userId) => {
return { token: generateSessionToken() };
},
}));Stateful (Traditional Server)
Use a challenge store if you need server-side challenge revocation.
import { PasskeyServer, MemoryChallengeStore, FileCredentialStore } from '@passkeykit/server';
const server = new PasskeyServer({
rpName: 'My App',
rpId: 'myapp.example.com',
allowedOrigins: ['https://myapp.example.com'],
challengeStore: new MemoryChallengeStore(),
credentialStore: new FileCredentialStore('./data/credentials.json'),
});Direct API (without Express)
// Registration
const regOptions = await server.generateRegistrationOptions(userId, userName);
// → send regOptions to client, client runs WebAuthn ceremony
const regResult = await server.verifyRegistration(attestationResponse, challengeToken);
// Authentication
const authOptions = await server.generateAuthenticationOptions();
// → send authOptions + sessionKey to client
const authResult = await server.verifyAuthentication(assertionResponse, sessionKey);Architecture
Client Server
│ │
│── POST /register/options ──────▶│ Generate challenge
│◀── { options, challengeToken } ──│ Seal into AES-256-GCM token
│ │
│── WebAuthn ceremony (browser) ──│
│ │
│── POST /register/verify ───────▶│ Open token, verify attestation
│ { response, challengeToken } │ No DB lookup needed
│◀── { verified: true } ──────────│In stateless mode, the challengeToken is an encrypted, signed, expiring token. The server needs only the secret key — zero state.
In stateful mode, challenges are stored in your ChallengeStore and consumed on verification.
Express Routes
Mount a complete passkey API with one line:
import { createExpressRoutes } from '@passkeykit/server/express';
const routes = createExpressRoutes(server, {
getUserInfo: async (userId) => ({ id: userId, name: 'User' }),
onRegistrationSuccess: async (userId, credentialId) => {
console.log(`User ${userId} registered passkey ${credentialId}`);
},
onAuthenticationSuccess: async (userId) => {
return { sessionToken: createSession(userId) };
},
});
app.use('/api/auth/passkey', routes);Routes created:
| Method | Path | Description |
|--------|------|-------------|
| POST | /register/options | Get registration options + challenge |
| POST | /register/verify | Verify attestation response |
| POST | /authenticate/options | Get authentication options + challenge |
| POST | /authenticate/verify | Verify assertion response |
| GET | /credentials/:userId | List user's credentials |
| DELETE | /credentials/:credentialId | Delete a credential |
Password Hashing
Built-in scrypt hashing — pure JS, works everywhere (no native bindings):
import { hashPassword, verifyPassword, needsRehash } from '@passkeykit/server';
const hash = await hashPassword('my-passphrase');
// → $scrypt$ln=17,r=8,p=1$<salt>$<hash>
const valid = await verifyPassword(hash, 'my-passphrase'); // true
// Check if params have been upgraded since this hash was created
if (needsRehash(hash)) {
const newHash = await hashPassword('my-passphrase');
await db.updateHash(userId, newHash);
}argon2 (optional)
For native argon2id hashing, install argon2 as a peer dependency:
npm install argon2import { hashPassword, verifyPassword } from '@passkeykit/server/argon2';
const hash = await hashPassword('my-passphrase');
// → $argon2id$v=19$m=65536,t=3,p=4$...Storage Backends
Built-in Stores
| Store | Use Case |
|-------|----------|
| MemoryChallengeStore | Development / testing |
| MemoryCredentialStore | Development / testing |
| FileChallengeStore | Single-server deployments |
| FileCredentialStore | Single-server deployments |
Custom Stores
Implement the ChallengeStore and/or CredentialStore interfaces for your backend:
import type { CredentialStore, StoredCredential } from '@passkeykit/server';
class FirestoreCredentialStore implements CredentialStore {
async save(credential: StoredCredential) { /* ... */ }
async getByUserId(userId: string) { /* ... */ }
async getByCredentialId(credentialId: string) { /* ... */ }
async updateCounter(credentialId: string, newCounter: number) { /* ... */ }
async delete(credentialId: string) { /* ... */ }
}import type { ChallengeStore, StoredChallenge } from '@passkeykit/server';
class RedisChallengeStore implements ChallengeStore {
async save(key: string, challenge: StoredChallenge) { /* ... */ }
async consume(key: string) { /* ... */ }
}In stateless mode, you don't need a ChallengeStore at all — just set encryptionKey.
Security Considerations
Stateless WebAuthn Replay Trade-off
When using stateless mode (encryptionKey), the challengeToken is a self-contained encrypted token. The server does not track single-use consumption — once issued, a token is valid until its expiry (default: 5 minutes).
Replay attacks within this window are safely mitigated natively by WebAuthn's signatureCounter validation, which is enforced by @simplewebauthn/server: an authenticator's counter must strictly increase on each use, so a replayed assertion with a stale counter is rejected at the protocol level.
Developers choosing stateless mode should understand this architectural shift: challenge uniqueness is guaranteed by the authenticator (via counter), not by server-side consumption tracking. If your threat model requires immediate server-side challenge revocation, use stateful mode with a ChallengeStore instead.
Configuration
interface PasskeyServerConfig {
rpName: string; // Shown to users during WebAuthn ceremony
rpId: string; // Must match the domain (e.g. 'example.com')
allowedOrigins: string[]; // e.g. ['https://example.com']
credentialStore: CredentialStore;
// Stateless mode (default — pick one):
encryptionKey?: string | string[]; // AES-256-GCM secret(s) — see Key Rotation below
// Stateful mode (alternative):
challengeStore?: ChallengeStore;
// Optional:
challengeTTL?: number; // Challenge expiry in ms (default: 5 minutes)
}Key Rotation
Pass an array of keys to rotate secrets without breaking in-flight ceremonies:
const server = new PasskeyServer({
// ...
encryptionKey: [
process.env.PASSKEY_SECRET_NEW!, // Current — used for encryption
process.env.PASSKEY_SECRET_OLD!, // Previous — still accepted for decryption
],
});- Encryption always uses the first key.
- Decryption tries each key in order until one succeeds.
- Once all in-flight tokens have expired (default: 5 minutes), remove the old key.
Runtime Compatibility
v3.0 uses the Web Crypto API (crypto.subtle) instead of node:crypto. This means the library runs natively on:
- ✅ Node.js 18+
- ✅ Deno
- ✅ Bun
- ✅ Cloudflare Workers
- ✅ Vercel Edge Runtime
Breaking change in v3.0:
sealChallengeTokenandopenChallengeTokenare now async (returnPromise). If you use PasskeyServer directly, this is handled internally. If you imported these functions directly, addawait.
Exports
| Import Path | Contents | Requires |
|-------------|----------|----------|
| @passkeykit/server | PasskeyServer, stores, password hashing, types | @simplewebauthn/server |
| @passkeykit/server/password | hashPassword(), verifyPassword(), needsRehash() — scrypt | None (pure JS) |
| @passkeykit/server/express | createExpressRoutes() — ready-made Express router | express |
| @passkeykit/server/argon2 | hashPassword(), verifyPassword() — native argon2id | argon2 |
Client Pairing
Use @passkeykit/client for the browser side. It handles the WebAuthn ceremony and challengeToken round-tripping automatically.
import { PasskeyClient } from '@passkeykit/client';
const client = new PasskeyClient({ serverUrl: '/api/auth/passkey' });
await client.register(userId, 'My Device');
await client.authenticate();Testing
npm test
npm run test:coverageLicense
MIT — GitHub
