Signing in. Two calls, and two flows that differ only in whether you name the account up front.
startAuthentication
startAuthenticationmethod
startAuthentication(input?: StartAuthenticationInput): Promise<StartAuthenticationResult>Issues request options. Call it with no arguments for the usernameless flow.
usernamestringScope the ceremony to one account. Usually unnecessary, see below.
userIdstringSame, by user handle, when you already know who is signing in. Useful for re-authentication before a sensitive action.
userVerification'required' | 'preferred' | 'discouraged'default config valueOverride for this ceremony. Recorded with the challenge and enforced at verification, so a stricter policy cannot be dropped between the two calls.
Returns
{ options: AuthenticationOptionsJSON; userId?: string }userId is present only when the ceremony was scoped to a known account.
The two flows
Usernameless: prefer this
const { options } = await passkeys.startAuthentication();allowCredentials is omitted. The browser lists every passkey for your domain
and shows a picker. The account is resolved from the user handle in the
response.
Username-first: only if you must
const { options } = await passkeys.startAuthentication({
username: 'ada',
});allowCredentials names that account's credentials. Needed for authenticators
that cannot store discoverable credentials.
Why usernameless is the recommended path
It is the entire point of passkeys. The browser already knows which credentials exist for your domain, so asking for a username first adds a screen, a text field and a failure mode without buying anything.
It is also better for security: with no allowCredentials, the response
reveals nothing about which accounts exist. See the enumeration note below.
An unknown username does not produce an error
startAuthentication({ username: 'nobody' }) returns a normal, well-formed
challenge with no allowCredentials.
Answering honestly would turn your login form into a free account-enumeration oracle: anyone could learn which usernames are registered by watching for a 404. The ceremony fails later at verification, exactly like any other bad attempt.
This means a typo'd username produces unknown_credential at the finish
step, not a helpful message at the start. That trade is deliberate.
finishAuthentication
finishAuthenticationmethod
finishAuthentication(response: AuthenticationResponseJSON): Promise<VerifyAuthenticationResult>Verifies the assertion. On success the returned user is authenticated.
Returns
{ verified: true; user; credential; signCount; userVerified }userVerified reports whether the authenticator checked a PIN or biometric, as
opposed to mere presence. Useful when you allow 'preferred' but want to demand
verification before a sensitive action.
const { user, userVerified } = await passkeys.finishAuthentication(req.body);
req.session.userId = user.id;
req.session.strongAuth = userVerified;The checks
| # | Check | Failure code | Specification |
|---|---|---|---|
| 1 | The response has the fields it must have | malformed_response | WebAuthn §7.2 step 1 |
| 2 | The challenge is one we issued and have not yet consumed | challenge_not_found | WebAuthn §7.2 step 11 |
| 3 | The challenge was issued for a login, not a registration | type_mismatch | WebAuthn §7.2 step 11 |
| 4 | The signed challenge equals the stored one | challenge_mismatch | WebAuthn §7.2 step 11 |
| 5 | clientData.type is exactly "webauthn.get" | type_mismatch | WebAuthn §7.2 step 10 |
| 6 | clientData.origin is in the allow-list | origin_mismatch | WebAuthn §7.2 step 12 |
| 7 | The ceremony did not run in a cross-origin frame | origin_mismatch | WebAuthn §7.2 step 13 |
| 8 | The credential ID is one we have stored | unknown_credential | WebAuthn §7.2 step 5 |
| 9 | The credential belongs to the account being signed into | unknown_credential | WebAuthn §7.2 step 6 |
| 10 | The user handle, when present, names the credential owner | unknown_credential | WebAuthn §7.2 step 6 |
| 11 | The RP ID hash matches this relying party | rpid_mismatch | WebAuthn §7.2 step 15 |
| 12 | The user-present flag is set | user_not_present | WebAuthn §7.2 step 16 |
| 13 | The user was verified, when the ceremony required it | user_not_verified | WebAuthn §7.2 step 17 |
| 14 | The signature verifies against the stored public key | bad_signature | WebAuthn §7.2 step 20 |
| 15 | The signature counter has not gone backwards | counter_regression | WebAuthn §7.2 step 21 |
Why the account check and the user-handle check both exist
They defend different flows.
The account check covers username-first: the ceremony was started for Ada, so a credential belonging to Bob must not satisfy it, even though Bob's credential is perfectly valid and his signature verifies.
The user-handle check covers usernameless: there is no scoped account, so the user handle in the response is what tells you who signed in. It has to agree with the credential's stored owner, or an attacker could present a valid signature from their own passkey alongside somebody else's handle.
Dropping either one leaves a hole in the flow the other does not cover.
The signature check
The authenticator signs authenticatorData concatenated with
SHA-256(clientDataJSON). passkify rebuilds that byte string, loads the stored
COSE public key, and verifies.
Why the stored key is raw COSE bytes rather than PEM
The credential record keeps publicKey as the exact CBOR the authenticator
produced. It is re-parsed on every login into a Node KeyObject through a JWK
import, which puts curve and point validation inside OpenSSL rather than in
library code.
Storing a converted format would mean the conversion happened once, at registration, with no way to revisit it if a bug were found. Keeping the original means the parse is re-run and re-validated on every use, and a fix applies retroactively to credentials already in your database.
The signature counter
Some authenticators increment a counter on every signature. If one arrives lower than the stored value, the same credential has signed twice from different states, which is what a cloned authenticator looks like.
hooks: {
onCounterRegression: ({ user, storedCounter, presentedCounter }) => {
logger.warn('counter went backwards', { userId: user.id, storedCounter, presentedCounter });
return false; // reject
},
}Most passkeys report zero forever
Synced credentials cannot maintain a coherent counter across devices, so they do not try. passkify skips the comparison when both the stored and presented counters are zero; otherwise every second login would fail.
Treat the counter as a detection signal, not a defence. An attacker holding the private key can report whatever counter they like.
Re-authentication
For a sensitive action, run a second ceremony scoped to the signed-in account:
app.post('/settings/danger/start', requireAuth, async (req, res) => {
const { options } = await passkeys.startAuthentication({
userId: req.session.userId,
userVerification: 'required', // demand a biometric this time
});
res.json(options);
});Because the requirement is recorded with the challenge, finishAuthentication
enforces 'required' for this ceremony even though the server default is
'preferred'.
Next
- Credential management
- HTTP adapters to skip writing these routes
- Security model for the reasoning behind each check