Why Passkeys Are Not Automatically Secure
Passkeys — the consumer-facing name for discoverable FIDO2 credentials built on the WebAuthn standard — are the correct direction for authentication security. They are phishing-resistant by design (credential binding is cryptographically tied to the relying party origin), they eliminate reusable passwords, and they resist credential stuffing attacks entirely.
But “phishing-resistant” doesn’t mean “implementation-resistant.” WebAuthn is a complex specification, and incorrect implementation can reintroduce vulnerabilities that passkeys are supposed to eliminate. Before the community convinced developers to move from SHA-1 to SHA-256, we saw widespread incorrect implementations. WebAuthn is repeating that pattern at higher complexity.
This guide covers what you need to get right.
The WebAuthn Ceremony in Plain Terms
WebAuthn has two flows:
Registration: The user creates a credential on their authenticator (platform authenticator like Face ID/Windows Hello, or a hardware key). The authenticator generates an asymmetric key pair, returns the public key + attestation to your server, and stores the private key securely on the device.
Authentication: Your server sends a challenge. The authenticator signs it with the credential’s private key. Your server verifies the signature against the stored public key.
The phishing resistance comes from origin binding: the authenticator’s signed clientDataJSON includes the origin (https://yourdomain.com). If a phishing page at https://evil-yourdomain.com intercepts the authentication flow and tries to replay the signed response to your real server, verification fails — the signed origin won’t match your expected rpId.
Critical Verification Steps You Must Not Skip
The WebAuthn spec defines a verification procedure. Skipping any step can reintroduce vulnerabilities.
1. Verify the rpId Hash
The authData blob contains the SHA-256 hash of the relying party ID (rpId, typically your domain). You must verify this matches your expected rpId:
import hashlib
def verify_rp_id_hash(auth_data: bytes, expected_rp_id: str) -> bool:
# First 32 bytes of authData are the rpIdHash
rp_id_hash = auth_data[:32]
expected_hash = hashlib.sha256(expected_rp_id.encode()).digest()
return rp_id_hash == expected_hash
If you skip this check, an authenticator credential registered on one of your subdomains could be replayed against another.
2. Verify the Origin in clientDataJSON
// Server-side Node.js verification
function verifyClientData(clientDataJSON, challenge, expectedOrigin) {
const clientData = JSON.parse(
Buffer.from(clientDataJSON, 'base64').toString('utf8')
);
// 1. Type must be 'webauthn.create' (registration) or 'webauthn.get' (auth)
if (clientData.type !== 'webauthn.get') {
throw new Error('Invalid clientData type');
}
// 2. Challenge must match — compare in constant time
const receivedChallenge = Buffer.from(clientData.challenge, 'base64url');
const expectedChallengeBuf = Buffer.from(challenge, 'base64url');
if (!crypto.timingSafeEqual(receivedChallenge, expectedChallengeBuf)) {
throw new Error('Challenge mismatch');
}
// 3. Origin must exactly match — no partial match, no suffix match
if (clientData.origin !== expectedOrigin) {
throw new Error(`Origin mismatch: got ${clientData.origin}, expected ${expectedOrigin}`);
}
}
Common mistake: checking that the origin “contains” your domain rather than exactly equals it. https://attacker.com?r=yourdomain.com contains your domain but is not your domain.
3. Verify the Signature
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric.ec import ECDSA
from cryptography.hazmat.primitives.asymmetric.rsa import PKCS1v15
import hashlib
def verify_assertion_signature(
public_key, # cryptography public key object
auth_data: bytes,
client_data_hash: bytes,
signature: bytes,
algorithm: int # COSE algorithm identifier (-7 = ES256, -257 = RS256)
) -> bool:
# The signed data is authData || SHA-256(clientDataJSON)
signed_data = auth_data + client_data_hash
try:
if algorithm == -7: # ES256 (P-256)
public_key.verify(signature, signed_data, ECDSA(hashes.SHA256()))
elif algorithm == -257: # RS256
public_key.verify(signature, signed_data, PKCS1v15(), hashes.SHA256())
else:
raise ValueError(f"Unsupported algorithm: {algorithm}")
return True
except Exception:
return False
4. Verify the Signature Counter
Every authenticator maintains a monotonically increasing signature counter (signCount). Store it per credential and verify it increments on each authentication:
def verify_sign_count(
stored_sign_count: int,
received_sign_count: int,
credential_id: str
) -> None:
if stored_sign_count == 0 and received_sign_count == 0:
# Authenticator doesn't implement counter (platform authenticators often don't)
# This is acceptable per spec
return
if received_sign_count <= stored_sign_count:
# Counter went backwards or didn't increment — possible cloned authenticator
raise SecurityError(
f"Possible credential cloning detected for {credential_id}: "
f"received={received_sign_count}, stored={stored_sign_count}"
)
A counter that regresses indicates the private key may have been exported and used on another device — a strong signal of credential theft.
5. Challenge Freshness and Single-Use
Challenges must be:
- Cryptographically random: minimum 16 bytes from a CSPRNG
- Single-use: mark the challenge as consumed after verification
- Time-bounded: expire after 60-300 seconds
const crypto = require('crypto');
// Generate challenge
function generateChallenge() {
return crypto.randomBytes(32); // 256-bit random challenge
}
// Store challenge with expiry
async function storeChallenge(sessionId, challenge) {
const expiresAt = Date.now() + (5 * 60 * 1000); // 5 minutes
await redis.setex(
`webauthn:challenge:${sessionId}`,
300, // 5 min TTL
JSON.stringify({ challenge: challenge.toString('base64url'), used: false, expiresAt })
);
}
// Consume challenge — atomic check-and-delete
async function consumeChallenge(sessionId, receivedChallenge) {
const key = `webauthn:challenge:${sessionId}`;
const stored = await redis.get(key);
if (!stored) throw new Error('Challenge not found or expired');
const { challenge, used } = JSON.parse(stored);
if (used) throw new Error('Challenge already consumed');
// Mark as used before verifying — prevents race condition
await redis.del(key);
const expected = Buffer.from(challenge, 'base64url');
const received = Buffer.from(receivedChallenge, 'base64url');
if (!crypto.timingSafeEqual(expected, received)) {
throw new Error('Challenge mismatch');
}
}
Storing Credentials Correctly
CREATE TABLE webauthn_credentials (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
credential_id BYTEA NOT NULL UNIQUE, -- raw bytes from credentialId
public_key BYTEA NOT NULL, -- COSE-encoded public key
sign_count INTEGER NOT NULL DEFAULT 0,
aaguid UUID, -- authenticator model identifier
attestation_type VARCHAR(50),
created_at TIMESTAMPTZ DEFAULT NOW(),
last_used_at TIMESTAMPTZ,
CONSTRAINT credential_id_length CHECK (length(credential_id) BETWEEN 16 AND 1023)
);
CREATE INDEX idx_credentials_user ON webauthn_credentials(user_id);
CREATE UNIQUE INDEX idx_credentials_id ON webauthn_credentials(credential_id);
Never store the private key. Only the public key is returned by the authenticator. The private key never leaves the hardware.
Account Recovery: The Hard Problem
Passkeys introduce a new support challenge: if a user loses all their authenticators, they cannot authenticate. Your recovery mechanism must be:
- As phishing-resistant as possible (avoid SMS/email recovery that undoes passkey security guarantees)
- Not weaker than the credential it recovers
- Practical for users
Common approaches:
- Multiple passkey registration: encourage users to register at least two authenticators at setup time
- Recovery codes: high-entropy single-use codes stored offline, equivalent strength to a strong password
- Identity verification flow: for high-value accounts, live identity verification before recovery
Recommended Libraries
Don’t implement WebAuthn from scratch. Use audited libraries:
| Platform | Library | Notes |
|---|---|---|
| Node.js | @simplewebauthn/server | Well-maintained, spec-compliant |
| Python | py_webauthn | Actively maintained, strict verification |
| Go | github.com/go-webauthn/webauthn | Used in Gitea, production-proven |
| Java | webauthn4j | Comprehensive, used in Spring Security |
Even with a library, read the verification code. Libraries can have bugs, and understanding what they check lets you catch misconfigurations — like passing the wrong expectedOrigin or disabling attestation validation.
Attestation: When It Matters
Attestation lets you verify the make and model of the authenticator. For consumer apps: skip it (it adds complexity and most authenticators don’t attest). For regulated environments (healthcare, finance, government) where you need to enforce authenticator type (e.g., FIDO2 hardware key only): verify attestation against the FIDO Metadata Service (MDS3).
WebAuthn passkeys are a significant security upgrade over passwords and TOTP. The implementation failure modes are subtle but consequential — a server that skips origin or rpId verification degrades to being phishable. Use a well-tested library, verify every field the spec requires, and test your implementation with a tool like webauthn-testing-tools before shipping.