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.

MethodPathBodySession
POST/register/start{ username?, displayName? }no
POST/register/finishthe browser's responseno
POST/login/start{ username? }no
POST/login/finishthe browser's responseno
GET/credentialsyes
PATCH/credentials/:id{ nickname }yes
DELETE/credentials/:idyes

Responses are JSON with cache-control: no-store.

Why no-store on every response

Express

expressmethod

express(options?: ExpressAdapterOptions): (req, res, next) => Promise<void>
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; },
}));
basePathstringdefault '/passkey'
getSessionUserId(req) => string | null | Promise<string | null>
onRegister(req, res, result) => void | Promise<void>
onLogin(req, res, result) => void | Promise<void>

Why session hooks live on the adapter, not on config.hooks

Behaviour worth knowing

Unmatched paths call next()pass through
It parses its own bodyno body-parser needed
5xx failures go to next(error)error handling

Fetch

handlermethod

handler(options?: FetchAdapterOptions): (request: Request) => Promise<Response>

For Next.js App Router, Hono, Bun, Deno, Remix and Cloudflare Workers.

// 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 };
basePathstringdefault '/passkey'
getSessionUserId(request: Request) => string | null | Promise<string | null>
onRegister(request, result) => void | Response | HeadersInit | Promise<...>
onLogin(request, result) => void | Response | HeadersInit | Promise<...>

Why the hooks can return either a Response or headers

Framework mounting

// 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);

Request and response shapes

Both finish routes accept the ceremony response either bare or wrapped:

// 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

Success:

{ verified: true, user: { id, username, displayName }, credentialId, isNewUser }

Failure, with the status from PasskeyError.status:

{ 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.

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 if your paths do not follow the /register/start convention.