WebAuthn and Passkeys: A Developer's Security Implementation Guide

Passkeys promise phishing-resistant authentication without passwords — but incorrect WebAuthn implementations introduce subtle vulnerabilities. This guide covers the cryptographic fundamentals, implementation mistakes to avoid, and secure code patterns in JavaScript and Python.

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

Don’t implement WebAuthn from scratch. Use audited libraries:

PlatformLibraryNotes
Node.js@simplewebauthn/serverWell-maintained, spec-compliant
Pythonpy_webauthnActively maintained, strict verification
Gogithub.com/go-webauthn/webauthnUsed in Gitea, production-proven
Javawebauthn4jComprehensive, 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.