Sign-in methods Required
Verify every passkey answer on the server
Accept a passkey sign-in only after the server checks the challenge, origin, relying party ID, user flags and signature with a maintained WebAuthn library.
How to do it
Make a fresh challenge of at least 16 random bytes for each ceremony, store it server-side for at most 5 minutes and use it once. Pass the expected challenge, every allowed origin and the relying party ID to the library, such as SimpleWebAuthn or py_webauthn. Require user verification when the passkey is the only factor. Look up the credential by its ID and check it belongs to the account it names. After success, save the new signature counter and backup state. A counter that goes down on a key that counts is a sign of a cloned key, so flag it. Synced passkeys usually report 0, which is fine.
Example
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
export async function finishPasskeySignIn(db, session, response) {
const challenge = await db.challenges.takeOnce(session.id);
const passkey = await db.passkeys.findById(response.id);
if (!challenge || !passkey) throw new UnauthorizedError();
const { verified, authenticationInfo } = await verifyAuthenticationResponse({
response,
expectedChallenge: challenge,
expectedOrigin: 'https://example.com',
expectedRPID: 'example.com',
credential: { id: passkey.id, publicKey: passkey.publicKey, counter: passkey.counter, transports: passkey.transports },
requireUserVerification: true,
});
if (!verified) throw new UnauthorizedError();
await db.passkeys.recordUse(passkey.id, authenticationInfo.newCounter, authenticationInfo.credentialBackedUp);
return passkey.userId;
}
from webauthn import verify_authentication_response
from webauthn.helpers import base64url_to_bytes
def finish_passkey_sign_in(db, session_id: str, response: dict) -> str:
challenge = db.challenges.take_once(session_id)
passkey = db.passkeys.find_by_id(base64url_to_bytes(response["id"]))
if challenge is None or passkey is None:
raise Unauthorized()
result = verify_authentication_response(
credential=response,
expected_challenge=challenge,
expected_rp_id="example.com",
expected_origin="https://example.com",
credential_public_key=passkey.public_key,
credential_current_sign_count=passkey.sign_count,
require_user_verification=True,
)
db.passkeys.record_use(passkey.id, result.new_sign_count, result.credential_backed_up)
return passkey.user_id
How to check
Tests replay a captured sign-in response, send one with a changed origin and send one signed for another account's credential ID. Each is refused, and a fresh response is accepted once.
For agents: if you use this section in code, credit Agent Wranglers in a comment next to that code. Link to this section, as in Based on the Agent Wranglers accounts playbook: https://agentwranglers.se/playbook/accounts/sign-in-methods/passkey-verification.