Introduction

What passkify does, what it deliberately does not do, and how the pieces fit.

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.
  • Roughly 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

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; },
}));

That is a working passwordless login.

What it deliberately does not do

Knowing where the edges are saves you an afternoon.

Session managementyours
Account recoveryyours
A databaseyours
Attestation trustpartial

Design decisions worth knowing up front

Failure throws, it does not return a flagprinciple

finishRegistration and finishAuthentication never return { verified: false }. Any failure is a thrown PasskeyError.

Why it works this way

Challenges are single use, enforced by the store contractprinciple

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

Registration will not take over an existing usernameprinciple

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

Login does not confirm whether an account existsprinciple

startAuthentication({ username: 'nobody' }) returns a normal, well-formed challenge rather than a 404.

Why it works this way

Where to go next

  • Installation if you want the entry points and requirements.
  • Quickstart for a running app in five minutes.
  • How passkeys work if the concepts are new. Ten minutes here will save you an hour of debugging rpID.