npm install passkifyWritten 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
ArrayBuffervalues that cannot surviveJSON.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 managementyoursA verified finishAuthentication means "this person proved possession of a
registered credential, just now". Turning that into a session is your
existing code's job, and passkify does not want to own your cookies.
Account recoveryyoursThe hardest part of going passwordless, and not something a library can decide. The security guide covers the options.
A databaseyourspasskify never talks to storage directly. You implement
PasskeyStore against whatever you already run.
Attestation trustpartialStatements are verified when present, but passkify does not ship a FIDO Metadata Service blob, so it will not claim an authenticator is a genuine YubiKey. See attestation.
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
A library that returns a result object invites if (result.verified), and
invites forgetting it. Forgetting it turns a failed verification into a
successful login. There is no such shape here to forget.
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
Deleting on read is what stops a captured response from being replayed. Putting the delete inside the store method rather than in calling code means there is no path through the library that forgets to do it.
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
Without this rule, anyone who knows a username could attach their own passkey to that account and sign in as them. It is the single most common way to get a passkey integration catastrophically wrong, so the API refuses to express it.
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
Answering honestly would turn the login form into a free account-enumeration oracle. The ceremony fails later at verification, exactly like any other bad attempt, and an attacker learns nothing.
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.