Two adapter shapes cover everything: Node-stream middleware, and a fetch handler. Pick the one your framework speaks.
Both halves run on an edge runtime
The verification path is pure WebCrypto — no node: built-in is imported
anywhere a ceremony can reach, and a test walks the source on every run to keep
it that way. Workers, Deno and Vercel Edge need no compatibility flag.
Express, Connect, Fastify
import express from 'express';
import { PasskeyServer } from 'passkify';
const app = express();
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; },
}));The middleware calls next() for anything that is not a passkey route, so it
is safe at the top of the stack.
It works without a body parser
If req.body is not already set, the adapter reads and parses the stream
itself, capped at 512 kB. That means the same middleware works on a bare
node:http server.
For Fastify, use @fastify/middie or fastify-express to mount Connect-style
middleware, or call the four methods directly from a Fastify route.
Next.js App Router
Create the shared instance
// lib/passkeys.ts
import { PasskeyServer } from 'passkify';
import { store } from './store';
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;
}The globalThis stash matters in development: Next re-evaluates modules on
every edit, and a fresh MemoryStore per edit discards every in-flight
challenge.
Mount the catch-all route
// app/api/passkey/[...passkey]/route.ts
import { cookies } from 'next/headers';
import { passkeys } from '@/lib/passkeys';
const handler = passkeys.handler({
basePath: '/api/passkey',
getSessionUserId: async () => (await cookies()).get('session')?.value ?? null,
onLogin: async (_request, { user }) => {
(await cookies()).set('session', user.id, {
httpOnly: true, secure: true, sameSite: 'lax', path: '/',
});
},
});
export { handler as GET, handler as POST, handler as PATCH, handler as DELETE };
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';Point the client at it
'use client';
import { configure, login } from 'passkify/client';
configure({ baseUrl: '/api/passkey' }); // must match basePathThe three things that trip people up
basePath must match the file path404 otherwiseapp/api/passkey/[...passkey] serves /api/passkey/*, so the handler needs
basePath: '/api/passkey' and the client needs the same baseUrl. A
mismatch returns a 404 whose body says exactly this.
Ceremonies must run client-sidebuild error otherwiseregister() and login() touch navigator.credentials. They belong in a
'use client' component or an event handler.
runtimenodejs or edgeEither works. The verification path is pure WebCrypto, so the route handler does not have to be pinned to the Node runtime.
MemoryStore does not work on serverless
A challenge issued by one instance is invisible to the next, so logins fail intermittently and confusingly. Postgres for users and credentials, Redis for challenges, is a good split. See store adapters.
Hono
import { Hono } from 'hono';
import { getCookie, setCookie } from 'hono/cookie';
const app = new Hono();
const handler = passkeys.handler({
basePath: '/passkey',
getSessionUserId: (request) => readSessionFrom(request),
});
app.all('/passkey/*', (c) => handler(c.req.raw));To set a cookie, return a HeadersInit from onLogin:
onLogin: (_request, { user }) => ({
'set-cookie': `session=${user.id}; HttpOnly; Secure; Path=/; SameSite=Lax`,
}),SvelteKit
// src/routes/passkey/[...path]/+server.ts
import { passkeys } from '$lib/server/passkeys';
const handler = passkeys.handler({ basePath: '/passkey' });
export const GET = ({ request }) => handler(request);
export const POST = ({ request }) => handler(request);
export const PATCH = ({ request }) => handler(request);
export const DELETE = ({ request }) => handler(request);Import from $lib/server/ so SvelteKit's module boundary keeps the server half
out of the client bundle for you.
Remix and React Router
// app/routes/passkey.$.tsx
const handler = passkeys.handler({ basePath: '/passkey' });
export const loader = ({ request }) => handler(request);
export const action = ({ request }) => handler(request);Bun
const handler = passkeys.handler({ basePath: '/passkey' });
Bun.serve({
port: 3000,
fetch(request) {
const url = new URL(request.url);
if (url.pathname.startsWith('/passkey')) return handler(request);
return new Response('Not found', { status: 404 });
},
});Deno
import { PasskeyServer } from 'npm:passkify';
const handler = passkeys.handler({ basePath: '/passkey' });
Deno.serve((request) => handler(request));Cloudflare Workers
No compatibility flag needed
Verification runs on the WebCrypto that Workers already provides, so
nodejs_compat is not required. Use passkeyWorker, which builds the server
per request because Workers hands bindings to fetch rather than to module
scope:
import { passkeyWorker } from 'passkify/cloudflare';
export default {
fetch: passkeyWorker((env) => buildPasskeyServer(env), { basePath: '/passkey' }),
};No framework at all
The adapters are convenience. The four methods are the API.
import { createServer } from 'node:http';
createServer(async (req, res) => {
if (req.url === '/passkey/login/start' && req.method === 'POST') {
const { options } = await passkeys.startAuthentication();
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify(options));
return;
}
// ...
}).listen(3000);Or mount passkeys.express() on the bare node:http server, which works
because the adapter parses its own body.