Failure throws
There is no verified: false to mistake for success. A library that returns one invites the check that gets forgotten.
Passwordless authentication
Client and server in one package. No runtime dependencies. Every check the specification asks for, on every login.
0
Runtime dependencies
The install tree is this package and nothing else.
92
Tests
Most of them feed the verifier tampered input and expect a throw.
29
Checks per ceremony pair
Counted across registration and authentication together.
6.5
Kilobytes in the browser
The client half, minified. The verifier never ships to a user.
Why this is worth changing
What you hold today
A hash of something your user probably reused. It sits in your database until the day it does not, and on that day the damage is theirs, not yours. Phishing does not even need the breach: a convincing copy of your login page is enough, because the user is the one being asked to recognise it.
What you hold instead
A public key. It verifies signatures and can do nothing else. Publish it on a billboard and no account is closer to being taken. The private half never leaves the device it was made on, and the browser will only sign for the exact domain that made it.
One login, start to finish
Challenge
Thirty-two random bytes, recorded once against the ceremony and valid for five minutes. Reading it deletes it, so the same challenge cannot be answered twice.
const options = await passkeys.startAuthentication();
// { challenge: 'Yk3n...', rpId: 'acme.com',
// userVerification: 'preferred', timeout: 300000 }
res.json(options);Signature
The browser will only sign for the domain the credential was made for. A convincing replica of your login page asks the authenticator for a signature and is refused, because the origin does not match.
import { login } from 'passkify/client';
// Touch ID, Face ID, Windows Hello, or a key
// in the hand. No username, no password field.
const user = await login();Verification
Origin, domain binding, challenge, flags, signature, counter. Any one of them failing throws a typed error. There is no result object with a boolean on it that a caller can forget to read.
try {
const { user } = await passkeys.verifyAuthentication(body);
req.session.userId = user.id;
} catch (error) {
if (isPasskeyError(error)) {
// error.code: 'bad_signature' | 'origin_mismatch' | ...
}
}Session
Then you open the session exactly as you already do. passkify has no opinion about your session library, your user table, or your framework, and it does not want one.
app.use(passkeys.express({
onLogin: (req, res, { user }) => {
req.session.userId = user.id;
},
}));Verification
These are the checks the authentication path runs, in the order it runs them. Each label is a real member of the exported error type, thrown at that exact point.
01
challenge_not_found
The challenge exists and has not expired.
02
type_mismatch
The client data says webauthn.get, not webauthn.create.
03
challenge_mismatch
The signed challenge is the one this server issued.
04
origin_mismatch
The origin is one of the origins you configured.
05
unknown_credential
The credential ID is registered here.
06
malformed_response
The user handle decodes as base64url UTF-8.
07
unknown_credential
The credential belongs to the account claiming it.
08
rpid_mismatch
The RP ID hash matches your domain, not a neighbour of it.
09
user_not_present
The user-present flag is set on the authenticator data.
10
user_not_verified
The human was verified when your config required it.
11
bad_signature
The signature verifies against the stored public key.
12
unknown_user
The account that owns the passkey still exists.
13
counter_regression
The signature counter has not moved backwards.
Design rules
There is no verified: false to mistake for success. A library that returns one invites the check that gets forgotten.
Reading a challenge deletes it, whether verification then succeeds or fails. Replay finds nothing to match.
Registration refuses an existing username. Adding a passkey to an account requires a session, never a request body.
An unknown username receives an ordinary challenge. Nobody learns which accounts exist by asking.
Read it, then run it
Nothing on the demo page is simulated. It mounts a real PasskeyServer over a memory store and verifies the assertion your own authenticator produces. If it works there, the same twelve lines work in your application.
const handler = passkeys.handler({
basePath: '/api/passkey',
getSessionUserId: (req) => readSession(req),
});
export {
handler as GET, handler as POST,
handler as PATCH, handler as DELETE,
};The documentation covers every method and every option, and says why each one is shaped the way it is.