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.
33
Verification checks
18 on registration and 15 on login, each one a registered assertion in the source.
27
Typed error codes
Every failure arrives as one of these, with an HTTP status attached.
3.6
Kilobytes over the wire
The client half, minified and compressed. 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
malformed_response
The response has the fields it must have
02
challenge_not_found
The challenge is one we issued and have not yet consumed
03
type_mismatch
The challenge was issued for a login, not a registration
04
challenge_mismatch
The signed challenge equals the stored one
05
type_mismatch
clientData.type is exactly "webauthn.get"
06
origin_mismatch
clientData.origin is in the allow-list
07
origin_mismatch
The ceremony did not run in a cross-origin frame
08
unknown_credential
The credential ID is one we have stored
09
unknown_credential
The credential belongs to the account being signed into
10
unknown_credential
The user handle, when present, names the credential owner
11
rpid_mismatch
The RP ID hash matches this relying party
12
user_not_present
The user-present flag is set
13
user_not_verified
The user was verified, when the ceremony required it
14
bad_signature
The signature verifies against the stored public key
15
counter_regression
The signature counter has not gone 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, that same route handler works 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.