npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@passkeykit/server

v3.1.2

Published

Server-side WebAuthn passkey verification — stateless or stateful, pure JS, works on serverless

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.

npm license

Install

npm install @passkeykit/server @simplewebauthn/server

@simplewebauthn/server is 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/server
import { 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 argon2
import { 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: sealChallengeToken and openChallengeToken are now async (return Promise). If you use PasskeyServer directly, this is handled internally. If you imported these functions directly, add await.

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:coverage

License

MIT — GitHub