Verifying Passkey Authentication Assertions
The browser returns a signed assertion, your verifyAuthenticationResponse call says verified: true, and you log the user in — but you never advanced the signature counter, so a cloned credential would sail straight through. This page is part of the passkeys and WebAuthn walkthrough, and it covers the authentication ceremony in detail: generateAuthenticationOptions, verifyAuthenticationResponse, the clone-detection counter check, user-verification flags, and how to bind a successful assertion to a real server-side session.
Root cause: an assertion only proves possession of one key
WebAuthn authentication (a W3C Recommendation over FIDO2/CTAP2) is the mirror of registration. The server issues a fresh challenge; the authenticator signs it with the private key it created during enrollment; the server verifies that signature against the stored public key. A valid signature proves one thing only: whoever performed the ceremony controls the private key for a specific credential ID at a specific origin. Everything else — which user that is, whether the credential was cloned, whether to mint a session — is logic you layer on top. Get that layering wrong and you can authenticate a replay, a clone, or the wrong account.
generateAuthenticationOptions
import { generateAuthenticationOptions } from "@simplewebauthn/server";
const options = await generateAuthenticationOptions({
rpID: "app.example.com",
userVerification: "preferred",
// allowCredentials omitted -> discoverable / usernameless login.
});
await sessionStore.set(req.sessionId, {
webauthnChallenge: options.challenge,
expiresAt: Date.now() + 60_000,
});
res.json(options);
allowCredentials decides the login style. Omit it and the authenticator presents any discoverable passkey it holds for your RP ID — the usernameless flow that makes passkeys feel effortless. Populate it (with the credential IDs for a known user) when the user has already identified themselves, e.g. typed an email first; the browser then only offers matching credentials. Use the second form for non-discoverable legacy credentials, which cannot be found without being named:
const creds = await db.getCredentialsForUser(user.id);
const options = await generateAuthenticationOptions({
rpID: "app.example.com",
userVerification: "preferred",
allowCredentials: creds.map((c) => ({
id: c.credentialID,
transports: c.transports,
})),
});
userVerification mirrors registration. "preferred" requests a biometric or PIN when the authenticator supports it; "required" makes user verification mandatory and fails authenticators that can only do user presence. Treat UV as the difference between single-factor (something you have) and two-factor (something you have plus something you are/know) — require it for sensitive sessions.
The challenge is again single-use and stored server-side, keyed to the session. The same global-variable and trust-the-client failure modes from registration apply identically here.
verifyAuthenticationResponse and the counter check
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
const stored = await sessionStore.get(req.sessionId);
if (!stored?.webauthnChallenge || stored.expiresAt < Date.now()) {
return res.status(400).json({ error: "challenge expired" });
}
// req.body.id is the credential the browser chose. Look it up first.
const credential = await db.getCredentialById(req.body.id);
if (!credential) return res.status(401).json({ error: "unknown credential" });
const verification = await verifyAuthenticationResponse({
response: req.body,
expectedChallenge: stored.webauthnChallenge,
expectedOrigin: "https://app.example.com",
expectedRPID: "app.example.com",
credential: {
id: credential.credentialID,
publicKey: credential.publicKey,
counter: credential.counter,
transports: credential.transports,
},
requireUserVerification: false,
});
if (!verification.verified) return res.status(401).json({ error: "bad assertion" });
verifyAuthenticationResponse checks the signature against the stored public key, confirms the challenge matches, and enforces origin and RP ID binding — the same byte-for-byte comparison that makes the credential unusable on a phishing domain.
The signature counter clone-detection check
A hardware authenticator maintains a monotonically increasing signature counter and returns its current value with each assertion. The rule: the counter in this assertion must be strictly greater than the value you last stored. If it isn’t, two copies of the same private key exist in the wild — a clone — and you should fail closed.
const { newCounter } = verification.authenticationInfo;
// Synced passkeys often report 0 and never increment. Treat 0/0 as
// "counter unsupported" and skip the check; otherwise enforce monotonicity.
const counterSupported = credential.counter !== 0 || newCounter !== 0;
if (counterSupported && newCounter <= credential.counter) {
await alertSecurity("possible_credential_clone", {
userId: credential.userId,
credentialID: credential.credentialID,
stored: credential.counter,
presented: newCounter,
});
return res.status(401).json({ error: "counter regression" });
}
await db.updateCounter(credential.credentialID, newCounter);
The crucial detail: synced passkeys (iCloud Keychain, Google Password Manager) typically report a counter of 0 on every device, because the same credential lives on several devices and a per-device counter is meaningless. So a stored 0 and a presented 0 is normal — treat it as “counter not supported” and skip the comparison rather than rejecting a legitimate login. Only a non-zero counter that fails to advance signals a clone.
Binding the assertion to a session
A verified assertion is an authentication event, not a session. Convert it into one explicitly: regenerate the session ID, attach the user, and set a hardened cookie.
// Verified — now establish the session.
await req.session.regenerate(); // new session ID, kills fixation
req.session.userId = credential.userId;
req.session.amr = ["webauthn"]; // record the auth method
req.session.uv = verification.authenticationInfo.userVerified; // 2FA signal
await req.session.save();
await sessionStore.delete(req.sessionId); // burn the challenge
res.cookie("sid", req.session.id, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: 8 * 60 * 60 * 1000,
});
res.json({ verified: true });
Regenerating the session ID at this boundary is what prevents session fixation — never promote a pre-login session ID to an authenticated one. Storing whether user verification happened (uv) lets downstream code decide if this session already satisfies a second factor or needs a step-up challenge for sensitive actions. The resulting session must live in an HttpOnly, Secure cookie so the assertion you worked to verify isn’t undone by a token readable from JavaScript.
Security implications
The assertion’s phishing resistance is only as strong as the origin and RP ID checks — never relax expectedOrigin to a wildcard or substring match. The challenge binding plus single-use deletion blocks replay of a captured assertion. The counter check is your only signal that a private key has been duplicated off a hardware authenticator; dropping it removes clone detection entirely. And because verification proves possession rather than identity, the credential-to-user lookup must come from your database via the credential ID — never trust a user identifier supplied in the request body alongside the assertion.
Prevention & monitoring
- Alert on counter regressions as a high-severity security event, not a generic login failure — it is the one signal of credential cloning you get.
- Record
amranduvon every session so audit logs can distinguish a single-factor passkey login from a user-verified one. - Rate-limit
/login/verifyby IP and by credential ID to blunt assertion-grinding, and alert on bursts ofunknown credentialresponses. - Track challenge-mismatch failures separately; a spike suggests replay attempts rather than user error.
Frequently Asked Questions
Why does the signature counter stay at 0 for my passkeys?
Synced passkeys exist on multiple devices at once, so a per-device incrementing counter would be meaningless and the platform reports 0 everywhere. This is expected. Treat a stored 0 and a presented 0 as “counter unsupported” and skip the monotonicity check. Single-device hardware keys (like a YubiKey) do increment, and for those you enforce strict greater-than.
Should I send allowCredentials or leave it empty?
Leave it empty for usernameless login: the authenticator presents any discoverable passkey for your RP ID and the user picks one. Populate it when the user has already identified themselves (e.g. entered an email) so the browser only offers that account’s credentials, or when you support non-discoverable legacy credentials that cannot be found without being named.
How do I know which user is logging in during usernameless flow?
The assertion’s id field is the credential ID the authenticator chose. Look that credential up in your database — it maps to exactly one user. The authenticator also returns a userHandle (the userID you set at registration), which you can use as a secondary check. Never trust a username sent alongside the assertion in the request body.
Does verifying an assertion count as two-factor authentication?
Only if user verification occurred. A passkey unlocked with a biometric or PIN is two factors in one gesture (possession of the device plus the biometric/PIN). Read verification.authenticationInfo.userVerified and require userVerification: "required" for sensitive sessions. A bare user-presence assertion (a tap without UV) is single-factor and should trigger a step-up before high-risk actions.
What stops an attacker from replaying a captured assertion?
The challenge. It is generated server-side per ceremony, stored bound to the session, and deleted after a successful verify. Because each assertion signs a unique challenge, a captured assertion can’t be resubmitted — the challenge it signed no longer exists in your store. This is why per-session challenge storage and burn-on-verify are mandatory, not optional.
Related
- Implementing passkeys and WebAuthn — the full ceremony model and configuration this verification step belongs to.
- Building the passkey registration ceremony — where the credentialID, publicKey, and counter you read here are first persisted.
- Regenerating session IDs after login — the session-fixation fix applied right after a successful assertion.