How to Add Passkey Login to a FastAPI App With WebAuthn and Block Replay, Wrong-Origin, and Account-Takeover Bugs
Build passkey sign-up and sign-in for a FastAPI app with py_webauthn, run it in Edge with a virtual authenticator, then attack your own server 13 ways and plant 10 bugs to prove the tests catch them.
This tutorial builds sign-up and sign-in with passkeys for a FastAPI app, from the first byte of a registration to a test suite that attacks the server. A passkey replaces the password with a key pair. The private key stays on the person’s device, your database keeps only the public key, and signing in means proving that you hold the private key by signing a fresh challenge. Your server never sees anything secret about the key, so there is no password to phish, reuse or leak from a stolen database.
Table Of Content
- What you will be able to do
- Prerequisites
- How a passkey works in plain words
- The two ceremonies
- Three identifiers that are easy to mix up
- Two flags and a counter
- Step 1: Create the project and install the tools
- Step 2: Look inside a registration before you build anything
- What each line is telling you
- Step 3: Design the database
- Step 4: Build the server
- 4.1 Settings and helpers
- 4.2 Sign up
- 4.3 Add another passkey
- 4.4 Sign in
- 4.5 Who am I, and sign out
- Step 5: Build the page
- Step 6: Run the whole thing in Edge
- What to check
- Step 7: Meet the four errors the browser reports
- Reading the four messages
- Step 8: Attack your own server
- Why each attack fails
- Attacks 1 to 3: replays
- Attacks 4 to 6: the wrong place
- Attacks 7 and 8: checks the client can skip
- Attacks 9 and 10: takeover and cloning
- Attacks 11 to 13: tampering and junk
- Step 9: Pin the defenses with tests
- Step 10: Prove the tests bite
- A mutant that survived, and what it taught me
- Step 11: Before you go to production
- Use HTTPS, the right RP ID and an exact origin list
- Verify email ownership
- Plan for recovery and for more than one passkey
- Decide what a counter rollback means for you
- Add rate limits and the usual web protections
- Verify the whole lab end to end
- Where to go next
- Sources
By the end you will have a small app where someone creates an account with an email address and a passkey, signs out, and signs back in with no username and no password. You will also have a lab that does what most passkey tutorials skip: it attacks the server. Thirteen attacks are all blocked: replays, a sign-in relayed from another origin, a missing user-verification check, a forged user handle, a sign-up that tries to take over an existing account, and a cloned key whose counter is behind. Then ten deliberately planted bugs show that the tests notice when a defense is removed.
Everything here was run on Windows 11 with Python 3.13.14, FastAPI 0.162.0, py_webauthn 3.0.1 (the library that checks passkey responses on the server) and Microsoft Edge 156.0.4314.50, driven by Playwright 1.64.1. You do not need a security key, a phone or Windows Hello. A browser feature called a virtual authenticator plays the part of the device. The idea for this lab came from freeCodeCamp’s guide to passkeys with Next.js and NestJS, which includes a section called “Security Mistakes to Avoid”. The code below is new Python, and it turns that kind of list into tests that fail when the mistake is made.
What you will be able to do
- Explain the parts of a passkey ceremony in plain words: authenticator, relying party, challenge, origin, RP ID, user handle, user verification and signature counter.
- Take a real registration response apart byte by byte and see what the server will check.
- Build passkey sign-up, sign-in, add-a-passkey and sign-out endpoints in FastAPI with SQLite storage.
- Run the whole flow in Edge with a virtual authenticator, and recognize the four errors the browser itself reports when a setting is wrong.
- Attack your own server with a software authenticator of under 100 lines, then pin every defense with tests that fail when the defense is removed.
Prerequisites
You need Python 3.10 or newer (the PyPI metadata for py_webauthn 3.0.1 asks for it), a browser that Playwright can drive, and basic FastAPI knowledge. You do not need to know WebAuthn. The database code uses DELETE ... RETURNING, which needs SQLite 3.35 or newer: the SQLite documentation says “The RETURNING syntax has been supported by SQLite since version 3.35.0 (2021-03-12).” Python 3.13.14 on this machine ships SQLite 3.50.4.
Playwright can control a browser you already have installed through its channel option. Its documentation lists the channels: “Available channels are chrome, msedge, chrome-beta, msedge-beta, chrome-dev, msedge-dev, chrome-canary, msedge-canary.” I ran everything in Edge. The code reads the channel from an environment variable, so on a machine with Chrome you can set BROWSER_CHANNEL=chrome (I did not test Chrome). The commands are for Windows; on macOS or Linux the only change is how you activate the virtual environment.
How a passkey works in plain words
Three parties take part. The relying party (RP) is your website and its server. The authenticator is the thing that holds private keys and signs: Windows Hello, a phone, a hardware security key, or in this lab a virtual one. The client is the browser, which carries messages between the page and the authenticator and writes down where the request came from. The W3C WebAuthn specification describes the two procedures that connect them as ceremonies, and WebAuthn Level 3 became a W3C Recommendation on 25 August 2026.
The two ceremonies
In the registration ceremony your server sends a random challenge, the authenticator creates a new key pair for your site and returns the public key, and your server stores it. In the authentication ceremony your server sends a new challenge, the authenticator signs it with the private key, and your server checks the signature against the stored public key.
Browser page Your FastAPI server Authenticator (the device)
------------ ------------------- --------------------------
1 POST /login/options -> make a random challenge, keep it
2 <- options and a one-time ceremony cookie
3 navigator.credentials.get(options) ----------------------> sign (authenticator data
+ hash of the client data)
4 <--------------------------------------------------------- response: signature, flags,
counter, user handle
5 POST /login/verify -> spend the challenge; check origin, RP ID, flags,
counter and signature against the stored public key
6 <- 200 and a fresh session cookie
Three identifiers that are easy to mix up
The RP ID is the domain a credential belongs to. The specification puts it this way: “A public key credential can only be used for authentication with the same entity (as identified by RP ID) it was registered with.” By default the RP ID is the domain of the page, and a page may choose a broader one “as long as the caller-specified RP ID value is a registrable domain suffix of or is equal to the caller’s origin’s effective domain”. In this lab the RP ID is localhost.
The origin is narrower: scheme, host and port, such as http://localhost:8000. The browser writes the origin of the calling page into the signed data, and your server compares it with the origins it expects. RP ID and origin are two different checks, and Step 8 shows an attack that passes the first and is stopped by the second.
The user handle is an opaque ID for the account that your server chooses. The specification calls it “an opaque byte sequence with a maximum size of 64 bytes” and says it “MUST NOT contain personally identifying information”, so it is random bytes, not the email address. The authenticator stores it next to the key, which is what lets a passkey tell your server who it belongs to without anyone typing a username. passkeys.dev describes such a key as a discoverable credential that “can be used by a user to log in to a relying party without initially providing a user ID”.
Two flags and a counter
Every signed response carries a one-byte set of flags. Two matter most here: user present (someone touched or approved) and user verified (the authenticator also checked a PIN, fingerprint or face). The response also carries a signature counter. The specification says “The signature counter’s purpose is to aid Relying Parties in detecting cloned authenticators” and also that “Authenticators that do not implement a signature counter leave the signCount in the authenticator data constant at zero.” You will meet both again in the attack lab.
Step 1: Create the project and install the tools
Make an empty folder, open a terminal in it, and run these three commands. The first creates a virtual environment so the packages stay inside the project, the second activates it, and the third installs the exact versions this tutorial was tested with. py_webauthn brings its own dependencies (cbor2 and cryptography among them), and Playwright needs no browser download because we point it at Edge.
python -m venv .venv
.venv\Scripts\activate
pip install fastapi==0.162.0 uvicorn==0.54.0 webauthn==3.0.1 playwright==1.64.1 pytest==9.2.0 requests==2.34.2
Check the result with pip list. These are the versions that matter:
cbor2 6.1.5
cryptography 50.0.2
fastapi 0.162.0
playwright 1.64.1
pytest 9.2.0
requests 2.34.2
starlette 1.10.0
uvicorn 0.54.0
webauthn 3.0.1
Every file below goes in this one folder, with the file name shown on the first line of its code block.
Step 2: Look inside a registration before you build anything
The fastest way to understand passkeys is to watch a real registration and take the response apart. That needs a browser with an authenticator, so the first file is the small harness the rest of the tutorial reuses. Read the comments: each option of the virtual authenticator is a thing a real device may or may not do.
# virtual.py
"""Drive Edge with a virtual authenticator: one browser context is one person with one device."""
import os
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from playwright.sync_api import sync_playwright
PORT, SIBLING_PORT = 8000, 8001
BASE = f"http://localhost:{PORT}"
SIBLING = f"http://localhost:{SIBLING_PORT}" # same site and same RP ID as BASE, but another origin
CHANNEL = os.environ.get("BROWSER_CHANNEL", "msedge") # use "chrome" on macOS or Linux
class SiblingPage:
"""A blank page on http://localhost:8001: another origin that shares the RP ID 'localhost'."""
def __enter__(self):
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
body = b"<!doctype html><title>sibling</title>"
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
self.httpd = HTTPServer(("127.0.0.1", SIBLING_PORT), Handler)
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
return self
def __exit__(self, *exc):
self.httpd.shutdown()
self.httpd.server_close()
CALL = """async ([name, args]) => {
try { return {ok: true, data: await window.passkeyLab[name](...args)}; }
catch (e) { return {ok: false, error: e.name + ": " + e.message}; }
}"""
CREATE = """async (options) => {
try {
const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(options);
return {ok: true, data: (await navigator.credentials.create({publicKey})).toJSON()};
} catch (e) { return {ok: false, error: e.name + ": " + e.message}; }
}"""
GET = """async (options) => {
try {
const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(options);
return {ok: true, data: (await navigator.credentials.get({publicKey})).toJSON()};
} catch (e) { return {ok: false, error: e.name + ": " + e.message}; }
}"""
class Person:
"""One person with one device: a browser context holding one virtual authenticator."""
def __init__(self, browser, start_url=BASE + "/"):
self.context = browser.new_context()
self.page = self.context.new_page()
self.page.goto(start_url)
self.cdp = self.context.new_cdp_session(self.page)
self.cdp.send("WebAuthn.enable")
authenticator = {
"protocol": "ctap2", # a modern security key or platform authenticator
"transport": "internal", # built in, like Windows Hello or Touch ID
"hasResidentKey": True, # it can store discoverable credentials
"hasUserVerification": True, # it can check a PIN or a fingerprint
"isUserVerified": True, # and that check succeeds
"automaticPresenceSimulation": True, # nobody has to touch it
}
added = self.cdp.send("WebAuthn.addVirtualAuthenticator", {"options": authenticator})
self.authenticator_id = added["authenticatorId"]
def call(self, name, *args):
"""Run window.passkeyLab.<name> from static/index.html: the real front-end code path."""
if not self.page.url.startswith(BASE):
self.page.goto(BASE + "/")
return self.page.evaluate(CALL, [name, list(args)])
def create(self, options, url=BASE + "/"):
self.page.goto(url)
return self.page.evaluate(CREATE, options)
def get(self, options, url=BASE + "/"):
self.page.goto(url)
return self.page.evaluate(GET, options)
def stored(self):
return self.cdp.send("WebAuthn.getCredentials", {"authenticatorId": self.authenticator_id})["credentials"]
def set_user_verified(self, value: bool):
self.cdp.send("WebAuthn.setUserVerified", {"authenticatorId": self.authenticator_id, "isUserVerified": value})
def close(self):
self.context.close()
class Edge:
"""Playwright plus an installed Edge (or Chrome); no browser download is needed."""
def __enter__(self):
self.playwright = sync_playwright().start()
self.browser = self.playwright.chromium.launch(channel=CHANNEL, headless=True)
self.people = []
return self
def person(self, **kwargs) -> Person:
self.people.append(Person(self.browser, **kwargs))
return self.people[-1]
def __exit__(self, *exc):
for person in self.people:
person.close()
self.browser.close()
self.playwright.stop()
Three ideas are packed into that file. First, SiblingPage serves a blank page on http://localhost:8001. WebAuthn only works on secure pages (MDN marks the API as available only in secure contexts), and MDN says that “Top-level documents provide a secure context when their URL is a potentially trustworthy URL”, with http://localhost in its list of secure examples, so a plain local page is enough. Second, Person talks to the browser through the Chrome DevTools Protocol. Playwright’s documentation says “The CDPSession instances are used to talk raw Chrome Devtools Protocol”, and the protocol’s WebAuthn domain exists for this job: “This domain allows configuring virtual authenticators to test the WebAuthn API.” The protocol file marks that domain as experimental, so method names could change in a future browser. Third, the page-side JavaScript uses two newer browser methods. MDN says PublicKeyCredential.parseCreationOptionsFromJSON() “creates a PublicKeyCredentialCreationOptions object from a JSON representation of its properties”, and toJSON() “returns a JSON type representation of a PublicKeyCredential”. MDN’s compatibility data lists Chrome 129, Firefox 119 and Safari 18.4 for them (Edge follows Chrome). They save you from hand-writing base64 conversions.
Now the script that registers a passkey and takes the answer apart. It asks py_webauthn for registration options, lets Edge create the credential, and prints what is inside.
# inspect_registration.py
"""Register a passkey in Edge and take the response apart, byte by byte."""
import hashlib
import io
import json
import struct
import uuid
import cbor2
from webauthn import generate_registration_options, options_to_json
from webauthn.helpers.structs import AuthenticatorSelectionCriteria, ResidentKeyRequirement, UserVerificationRequirement
from virtual import SIBLING, Edge, SiblingPage
from softkey import unb64u
selection = AuthenticatorSelectionCriteria(
resident_key=ResidentKeyRequirement.REQUIRED, user_verification=UserVerificationRequirement.REQUIRED
)
options = generate_registration_options(
rp_id="localhost", rp_name="Passkey Lab", user_name="[email protected]", authenticator_selection=selection
)
sent = json.loads(options_to_json(options))
print("what the server sends to the browser")
for name in ("rp", "attestation", "authenticatorSelection"):
print(f" {name}: {sent[name]}")
print(" pubKeyCredParams (COSE algorithm numbers):", [item["alg"] for item in sent["pubKeyCredParams"]])
print(" challenge:", len(unb64u(sent["challenge"])), "random bytes")
print(" user.id:", len(unb64u(sent["user"]["id"])), "random bytes")
with SiblingPage(), Edge() as edge: # a blank page on http://localhost:8001 is enough for this step
person = edge.person(start_url=SIBLING + "/")
reply = person.create(sent, url=SIBLING + "/")
credential = reply["data"]
print("\nwhat the browser sends back")
print(" top-level fields:", sorted(credential))
print(" response fields:", sorted(credential["response"]))
client = json.loads(unb64u(credential["response"]["clientDataJSON"]))
print("\nclientDataJSON, written by the browser")
print(" type:", client["type"], "| origin:", client["origin"], "| crossOrigin:", client["crossOrigin"])
print(" challenge is the one we sent:", client["challenge"] == sent["challenge"])
attestation = cbor2.loads(unb64u(credential["response"]["attestationObject"]))
auth = attestation["authData"]
print("\nattestationObject, written by the authenticator")
print(" format:", attestation["fmt"], "| attestation statement:", attestation["attStmt"])
print(" authData is", len(auth), "bytes")
print(" rpIdHash is SHA-256('localhost'):", auth[:32] == hashlib.sha256(b"localhost").digest())
flags = auth[32]
names = {0x01: "UP user present", 0x04: "UV user verified", 0x08: "BE backup eligible",
0x10: "BS backed up", 0x40: "AT credential data attached", 0x80: "ED extension data"}
print(" flags:", f"{flags:#04x}", "=", ", ".join(f"{name}={int(bool(flags & bit))}" for bit, name in names.items()))
print(" signCount:", struct.unpack(">I", auth[33:37])[0])
id_length = struct.unpack(">H", auth[53:55])[0]
print(" AAGUID:", uuid.UUID(bytes=auth[37:53]), "| credential id:", id_length, "bytes")
cose = cbor2.CBORDecoder(io.BytesIO(auth[55 + id_length:])).decode()
print(" public key (COSE):", {key: (value if isinstance(value, int) else f"{len(value)} bytes") for key, value in cose.items()})
Run it:
python inspect_registration.py
You should see this (the random values are shown only as lengths):
what the server sends to the browser
rp: {'name': 'Passkey Lab', 'id': 'localhost'}
attestation: none
authenticatorSelection: {'residentKey': 'required', 'requireResidentKey': True, 'userVerification': 'required'}
pubKeyCredParams (COSE algorithm numbers): [-8, -7, -257]
challenge: 64 random bytes
user.id: 64 random bytes
what the browser sends back
top-level fields: ['authenticatorAttachment', 'clientExtensionResults', 'id', 'rawId', 'response', 'type']
response fields: ['attestationObject', 'authenticatorData', 'clientDataJSON', 'publicKey', 'publicKeyAlgorithm', 'transports']
clientDataJSON, written by the browser
type: webauthn.create | origin: http://localhost:8001 | crossOrigin: False
challenge is the one we sent: True
attestationObject, written by the authenticator
format: none | attestation statement: {}
authData is 129 bytes
rpIdHash is SHA-256('localhost'): True
flags: 0x45 = UP user present=1, UV user verified=1, BE backup eligible=0, BS backed up=0, AT credential data attached=1, ED extension data=0
signCount: 1
AAGUID: 01020304-0506-0708-0102-030405060708 | credential id: 32 bytes
public key (COSE): {1: 1, 3: -8, -1: 6, -2: '32 bytes'}
What each line is telling you
The options. attestation: none means we do not ask the authenticator to prove which model it is. residentKey: required asks for a discoverable credential, and userVerification: required asks for a PIN, fingerprint or face check. The challenge is 64 random bytes; the specification only asks that challenges “MUST contain enough entropy to make guessing them infeasible” and “SHOULD therefore be at least 16 bytes long”, so py_webauthn’s default is generous.
The algorithm list. [-8, -7, -257] are COSE algorithm numbers: -8 is EdDSA, -7 is ES256 and -257 is RS256. The specification says the list is “ordered from most preferred to least preferred” and that “If none of the listed types can be created, the create() operation fails.” The py_webauthn changelog explains why EdDSA is first in version 3: generate_registration_options() “will now encourage authenticators to create keypairs using EDDSA when available, signaling acceptance of ES256 or RS256 when EDDSA is unavailable”. Edge’s virtual authenticator took the first one, so the last line shows an Ed25519 key (key type 1, curve 6; RFC 9053 lists OKP as 1 and Ed25519 as 6). The server will verify whichever of the three it receives.
clientDataJSON. The browser wrote this, not the authenticator. It holds the ceremony type, the challenge you sent and the origin of the page that made the call. Your server must check all three.
authData. The authenticator writes this part, and at sign-in it signs it together with a hash of the client data. It is 129 bytes here: a 32-byte hash of the RP ID (we confirmed it equals SHA-256 of localhost), one byte of flags, a four-byte counter, a 16-byte AAGUID (a model ID; this virtual authenticator reports 01020304-0506-0708-0102-030405060708), the credential ID and the public key. The flag byte 0x45 means user present, user verified and credential data attached. Backup eligible and backed up are both 0, so this credential would count as device-bound. The counter starts at 1.
Step 3: Design the database
Four tables are enough. users keeps the random user handle apart from the email address. credentials keeps one row per passkey with the public key and the last counter we saw. challenges remembers each challenge under a random ceremony ID for two minutes. sessions stores only a SHA-256 hash of the session token, so a leaked database does not hand out live sessions.
# db.py
"""SQLite storage for users, passkeys, one-time challenges and sessions."""
import hashlib
import os
import secrets
import sqlite3
import time
DB_PATH = os.environ.get("DB_PATH", "passkeys.db")
SCHEMA = """
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
handle BLOB NOT NULL UNIQUE, -- random 32 bytes, sent to the authenticator as user.id
email TEXT NOT NULL UNIQUE,
created_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS credentials (
credential_id BLOB PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
public_key BLOB NOT NULL, -- COSE key bytes exactly as py_webauthn returns them
sign_count INTEGER NOT NULL,
device_type TEXT NOT NULL,
backed_up INTEGER NOT NULL,
created_at REAL NOT NULL,
last_used_at REAL
);
CREATE TABLE IF NOT EXISTS challenges (
ceremony_id TEXT PRIMARY KEY,
kind TEXT NOT NULL, -- register, add or login
challenge BLOB NOT NULL,
email TEXT,
user_handle BLOB,
user_id INTEGER,
expires_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY, -- only a SHA-256 of the cookie value is stored
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at REAL NOT NULL
);
"""
def connect() -> sqlite3.Connection:
con = sqlite3.connect(DB_PATH, isolation_level=None, timeout=10) # autocommit; BEGIN is explicit
con.row_factory = sqlite3.Row
con.execute("PRAGMA foreign_keys = ON")
return con
def init() -> None:
con = connect()
con.executescript(SCHEMA)
con.close()
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def put_challenge(con, kind, challenge, *, email=None, user_handle=None, user_id=None, ttl=120) -> str:
"""Remember a challenge under a random ceremony id (the browser keeps the id in a cookie)."""
con.execute("DELETE FROM challenges WHERE expires_at < ?", (time.time(),))
ceremony_id = secrets.token_urlsafe(24)
con.execute(
"INSERT INTO challenges VALUES (?, ?, ?, ?, ?, ?, ?)",
(ceremony_id, kind, challenge, email, user_handle, user_id, time.time() + ttl),
)
return ceremony_id
def take_challenge(con, ceremony_id, kind):
"""Fetch and delete in one statement: a challenge is spent whether verification passes or fails."""
row = con.execute(
"DELETE FROM challenges WHERE ceremony_id = ? AND kind = ? RETURNING *", (ceremony_id, kind)
).fetchone()
if row is None or row["expires_at"] < time.time():
return None
return row
The most important function is take_challenge. It fetches and deletes the row in one statement, so a challenge can be used exactly once, whether the verification that follows succeeds or fails. That one decision, together with the expiry check beside it, is what attacks 1 and 3 in Step 8 run into, and the challenge comparison inside py_webauthn stops attack 2.
Step 4: Build the server
The server has six jobs: sign up, add another passkey, sign in, show who you are, sign out, and refuse everything else. We build it in five pieces.
4.1 Settings and helpers
Two settings decide which websites the server believes: RP_ID and ORIGINS. They come from the environment, never from the request. reject() gives the client a short generic message and writes the real reason to the log, so an attacker learns nothing about which check failed. start_session() always creates a brand-new random token and deletes the old one. selection() asks for a discoverable credential and mandatory user verification.
# app.py
"""Passkey sign-up and sign-in for FastAPI, using py_webauthn 3.0.1."""
import logging
import os
import re
import secrets
import sqlite3
import time
from contextlib import closing
from pathlib import Path
from fastapi import Cookie, Depends, FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse
from pydantic import BaseModel, field_validator
from webauthn import (
base64url_to_bytes,
generate_authentication_options,
generate_registration_options,
options_to_json,
verify_authentication_response,
verify_registration_response,
)
from webauthn.helpers import bytes_to_base64url
from webauthn.helpers.exceptions import WebAuthnException
from webauthn.helpers.structs import (
AuthenticatorSelectionCriteria,
PublicKeyCredentialDescriptor,
ResidentKeyRequirement,
UserVerificationRequirement,
)
import db
logging.basicConfig(level=logging.INFO, format="%(levelname)s passkeys: %(message)s")
log = logging.getLogger("passkeys")
RP_ID = os.environ.get("RP_ID", "localhost")
RP_NAME = "Passkey Lab"
ORIGINS = os.environ.get("ORIGINS", "http://localhost:8000").split(",")
SECURE_COOKIES = os.environ.get("COOKIE_SECURE") == "1"
SESSION_TTL = 3600
CHALLENGE_TTL = 120
VERIFY_ERRORS = (WebAuthnException, ValueError, KeyError, TypeError, AttributeError)
db.init()
app = FastAPI()
class EmailIn(BaseModel):
email: str
@field_validator("email")
@classmethod
def normalise(cls, value: str) -> str:
value = value.strip().lower()
if len(value) > 254 or not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", value):
raise ValueError("enter a valid email address")
return value
class CredentialIn(BaseModel):
credential: dict
def reject(status: int, message: str, where: str, exc: Exception | None = None):
"""Tell the client as little as possible and tell the log everything."""
detail = f" ({type(exc).__name__}: {exc})" if exc else ""
log.warning("%s rejected: %s%s", where, message, detail)
raise HTTPException(status, message)
def set_cookie(response: Response, name: str, value: str, max_age: int) -> None:
response.set_cookie(name, value, max_age=max_age, httponly=True, samesite="lax", secure=SECURE_COOKIES, path="/")
def options_response(options, ceremony_id: str) -> Response:
response = Response(content=options_to_json(options), media_type="application/json")
set_cookie(response, "ceremony", ceremony_id, CHALLENGE_TTL)
return response
def start_session(con, response: Response, user_id: int, old_token: str | None = None) -> None:
"""Always mint a fresh session token; never keep one the browser sent."""
if old_token:
con.execute("DELETE FROM sessions WHERE token_hash = ?", (db.hash_token(old_token),))
token = secrets.token_urlsafe(32)
con.execute("INSERT INTO sessions VALUES (?, ?, ?)", (db.hash_token(token), user_id, time.time() + SESSION_TTL))
set_cookie(response, "session", token, SESSION_TTL)
def current_user(session: str | None = Cookie(default=None)) -> sqlite3.Row:
with closing(db.connect()) as con:
row = con.execute(
"SELECT u.* FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ? AND s.expires_at > ?",
(db.hash_token(session or ""), time.time()),
).fetchone()
if row is None:
raise HTTPException(401, "sign in first")
return row
def selection() -> AuthenticatorSelectionCriteria:
"""Discoverable credentials (so sign-in needs no username) and mandatory user verification."""
return AuthenticatorSelectionCriteria(
resident_key=ResidentKeyRequirement.REQUIRED,
user_verification=UserVerificationRequirement.REQUIRED,
)
def store_credential(con, user_id: int, verified) -> None:
con.execute(
"INSERT INTO credentials (credential_id, user_id, public_key, sign_count, device_type, backed_up, created_at)"
" VALUES (?, ?, ?, ?, ?, ?, ?)",
(
verified.credential_id,
user_id,
verified.credential_public_key,
verified.sign_count,
verified.credential_device_type.value,
int(verified.credential_backed_up),
time.time(),
),
)
4.2 Sign up
Sign-up is two requests. /register/options picks a random 32-byte user handle, builds the options and stores the challenge together with the email and handle. If the email already has an account it answers 409 and nothing else: sign-up must never attach a new passkey to an existing account, because anyone can type anyone’s email address. /register/verify spends the challenge, lets py_webauthn check the response against the pinned RP ID and origin list with user verification required, and then writes the user and the credential in one transaction.
# app.py (continued)
@app.get("/")
def index():
return FileResponse(Path(__file__).parent / "static" / "index.html")
@app.get("/health")
def health():
return {"ok": True}
# ---- sign up: create an account and its first passkey ----------------------------------------
@app.post("/register/options")
def register_options(body: EmailIn):
handle = secrets.token_bytes(32)
options = generate_registration_options(
rp_id=RP_ID,
rp_name=RP_NAME,
user_id=handle,
user_name=body.email,
user_display_name=body.email,
authenticator_selection=selection(),
)
with closing(db.connect()) as con:
if con.execute("SELECT 1 FROM users WHERE email = ?", (body.email,)).fetchone():
reject(409, "that email already has an account; sign in and add a passkey instead", "register")
ceremony_id = db.put_challenge(con, "register", options.challenge, email=body.email, user_handle=handle)
return options_response(options, ceremony_id)
@app.post("/register/verify", status_code=201)
def register_verify(body: CredentialIn, request: Request, response: Response):
with closing(db.connect()) as con:
ceremony = db.take_challenge(con, request.cookies.get("ceremony"), "register")
if ceremony is None:
reject(400, "registration expired; start again", "register", ValueError("challenge missing or used"))
try:
verified = verify_registration_response(
credential=body.credential,
expected_challenge=ceremony["challenge"],
expected_rp_id=RP_ID,
expected_origin=ORIGINS,
require_user_verification=True,
)
except VERIFY_ERRORS as exc:
reject(400, "registration failed", "register", exc)
try:
con.execute("BEGIN IMMEDIATE")
cursor = con.execute(
"INSERT INTO users (handle, email, created_at) VALUES (?, ?, ?)",
(ceremony["user_handle"], ceremony["email"], time.time()),
)
user_id = cursor.lastrowid
store_credential(con, user_id, verified)
con.execute("COMMIT")
except sqlite3.IntegrityError:
con.execute("ROLLBACK")
reject(409, "that email already has an account", "register")
start_session(con, response, user_id, request.cookies.get("session"))
return {"email": ceremony["email"]}
4.3 Add another passkey
A signed-in person may add a second passkey (a laptop and a phone, for example). The Depends(current_user) parameter is what makes this safe: no session, no options. The server also lists the existing credentials in exclude_credentials, which tells a compliant browser not to register the same authenticator twice (the tests check that the list is sent).
# app.py (continued)
# ---- add another passkey: only for a user who is already signed in ----------------------------
@app.post("/passkeys/options")
def add_options(user: sqlite3.Row = Depends(current_user)):
with closing(db.connect()) as con:
known = [
PublicKeyCredentialDescriptor(id=row["credential_id"])
for row in con.execute("SELECT credential_id FROM credentials WHERE user_id = ?", (user["id"],))
]
options = generate_registration_options(
rp_id=RP_ID,
rp_name=RP_NAME,
user_id=user["handle"],
user_name=user["email"],
user_display_name=user["email"],
authenticator_selection=selection(),
exclude_credentials=known,
)
ceremony_id = db.put_challenge(con, "add", options.challenge, user_id=user["id"])
return options_response(options, ceremony_id)
@app.post("/passkeys/verify", status_code=201)
def add_verify(body: CredentialIn, request: Request, user: sqlite3.Row = Depends(current_user)):
with closing(db.connect()) as con:
ceremony = db.take_challenge(con, request.cookies.get("ceremony"), "add")
if ceremony is None or ceremony["user_id"] != user["id"]:
reject(400, "registration expired; start again", "add passkey", ValueError("challenge missing or used"))
try:
verified = verify_registration_response(
credential=body.credential,
expected_challenge=ceremony["challenge"],
expected_rp_id=RP_ID,
expected_origin=ORIGINS,
require_user_verification=True,
)
store_credential(con, user["id"], verified)
except VERIFY_ERRORS + (sqlite3.IntegrityError,) as exc:
reject(400, "registration failed", "add passkey", exc)
return {"added": True}
4.4 Sign in
Sign-in is the heart of the app, so read it slowly. /login/options sends a challenge and no list of allowed credentials, because we do not know who is signing in yet. /login/verify then does what the specification describes for this case. The relevant step reads: “verify that response.userHandle is present. Verify that the user account identified by response.userHandle contains a credential record whose id equals credential.rawId.” The SQL join implements that sentence: the credential must exist and its owner’s handle must equal the handle in the response.
The counter comes next. The specification says: “If authData.signCount is nonzero or credentialRecord.signCount is nonzero, then run the following sub-step”, and a counter that does not go up “is a signal, but not proof, that the authenticator may be cloned”. py_webauthn applies that rule when we pass it the stored count, and afterwards we save the new count. Finally the user-verification rule: “User verification SHOULD be required if, and only if, pkOptions.userVerification is set to required.” We required it in the options, so we require it in the verification too (require_user_verification=True). If we only asked, a hostile client could simply not do it.
# app.py (continued)
# ---- sign in: no username, the passkey itself says who it belongs to ---------------------------
@app.post("/login/options")
def login_options():
options = generate_authentication_options(rp_id=RP_ID, user_verification=UserVerificationRequirement.REQUIRED)
with closing(db.connect()) as con:
ceremony_id = db.put_challenge(con, "login", options.challenge)
return options_response(options, ceremony_id)
@app.post("/login/verify")
def login_verify(body: CredentialIn, request: Request, response: Response):
with closing(db.connect()) as con:
ceremony = db.take_challenge(con, request.cookies.get("ceremony"), "login")
if ceremony is None:
reject(401, "sign-in failed", "login", ValueError("challenge missing, expired or used"))
try:
credential_id = base64url_to_bytes(body.credential["rawId"])
user_handle = base64url_to_bytes(body.credential["response"]["userHandle"])
except VERIFY_ERRORS as exc:
reject(401, "sign-in failed", "login", exc)
stored = con.execute(
"SELECT c.* FROM credentials c JOIN users u ON u.id = c.user_id"
" WHERE c.credential_id = ? AND u.handle = ?",
(credential_id, user_handle),
).fetchone()
if stored is None:
reject(401, "sign-in failed", "login", ValueError("no such passkey for that user handle"))
try:
verified = verify_authentication_response(
credential=body.credential,
expected_challenge=ceremony["challenge"],
expected_rp_id=RP_ID,
expected_origin=ORIGINS,
credential_public_key=stored["public_key"],
credential_current_sign_count=stored["sign_count"],
require_user_verification=True,
)
except VERIFY_ERRORS as exc:
reject(401, "sign-in failed", "login", exc)
con.execute(
"UPDATE credentials SET sign_count = ?, last_used_at = ? WHERE credential_id = ?",
(verified.new_sign_count, time.time(), stored["credential_id"]),
)
start_session(con, response, stored["user_id"], request.cookies.get("session"))
return {"signed_in": True}
4.5 Who am I, and sign out
These two small endpoints let a person see their account and end the session. /me returns only the first eight characters of each credential ID, which is enough to tell passkeys apart without exposing them.
# app.py (continued)
@app.get("/me")
def me(user: sqlite3.Row = Depends(current_user)):
with closing(db.connect()) as con:
rows = con.execute(
"SELECT credential_id, device_type, backed_up, sign_count FROM credentials"
" WHERE user_id = ? ORDER BY created_at",
(user["id"],),
).fetchall()
passkeys = [
{
"id": bytes_to_base64url(row["credential_id"])[:8],
"device_type": row["device_type"],
"backed_up": bool(row["backed_up"]),
"sign_count": row["sign_count"],
}
for row in rows
]
return {"email": user["email"], "passkeys": passkeys}
@app.post("/logout")
def logout(response: Response, session: str | None = Cookie(default=None)):
if session:
with closing(db.connect()) as con:
con.execute("DELETE FROM sessions WHERE token_hash = ?", (db.hash_token(session),))
response.delete_cookie("session", path="/")
return {"signed_out": True}
Step 5: Build the page
The page needs very little code. post() sends JSON and turns an error response into a thrown error. ceremony() runs the same three moves for every flow: ask the server for options, hand them to the browser, and send the browser’s answer back. The object window.passkeyLab exposes the flows so that our test harness can call exactly the code a user’s click would run.
<!doctype html>
<meta charset="utf-8">
<title>Passkey lab</title>
<h1>Passkey lab</h1>
<p><input id="email" type="email" placeholder="[email protected]"></p>
<p>
<button id="signup">Create account with a passkey</button>
<button id="signin">Sign in with a passkey</button>
<button id="add">Add another passkey</button>
<button id="signout">Sign out</button>
</p>
<pre id="log"></pre>
<script>
async function post(path, body) {
const res = await fetch(path, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(body ?? {}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || res.statusText);
return data;
}
async function ceremony(method, optionsPath, verifyPath, body) {
const options = await post(optionsPath, body);
const publicKey = method === "create"
? PublicKeyCredential.parseCreationOptionsFromJSON(options)
: PublicKeyCredential.parseRequestOptionsFromJSON(options);
const credential = await navigator.credentials[method]({publicKey});
return post(verifyPath, {credential: credential.toJSON()});
}
const api = {
signup: (email) => ceremony("create", "/register/options", "/register/verify", {email}),
signin: () => ceremony("get", "/login/options", "/login/verify"),
add: () => ceremony("create", "/passkeys/options", "/passkeys/verify"),
me: async () => {
const res = await fetch("/me");
return {status: res.status, body: await res.json()};
},
signout: () => post("/logout"),
};
window.passkeyLab = api;
const show = (value) => {
document.getElementById("log").textContent = typeof value === "string" ? value : JSON.stringify(value, null, 2);
};
const run = (fn) => async () => {
try { show(await fn()); } catch (e) { show(`${e.name}: ${e.message}`); }
};
const email = () => document.getElementById("email").value;
document.getElementById("signup").onclick = run(async () => { await api.signup(email()); return api.me(); });
document.getElementById("signin").onclick = run(async () => { await api.signin(); return api.me(); });
document.getElementById("add").onclick = run(async () => { await api.add(); return api.me(); });
document.getElementById("signout").onclick = run(() => api.signout());
</script>
Notice what is missing: no base64 helpers and no password field, and the sign-in button needs no username. The browser’s two JSON methods do the conversions.
Step 6: Run the whole thing in Edge
To run the app from a script we need a small helper that starts uvicorn with a throwaway database and stops it afterwards. One detail matters on Windows and is easy to miss: it binds uvicorn to localhost, not 127.0.0.1.
# server.py
"""Run the app under uvicorn with a throwaway database, for the step scripts and the tests."""
import os
import shutil
import sqlite3
import subprocess
import sys
import tempfile
import time
import requests
from virtual import BASE, PORT
HERE = os.path.dirname(os.path.abspath(__file__))
class Server:
"""Run app.py with uvicorn from APP_DIR (default: this folder) and a throwaway database."""
def __init__(self, app_dir=None):
self.app_dir = app_dir or os.environ.get("APP_DIR", HERE)
self.tmp = tempfile.mkdtemp(prefix="passkeys-")
self.db_path = os.path.join(self.tmp, "lab.db")
self.log_path = os.path.join(self.tmp, "server.log")
def __enter__(self):
env = dict(os.environ, DB_PATH=self.db_path, ORIGINS=BASE, RP_ID="localhost")
self.log = open(self.log_path, "wb")
cmd = [sys.executable, "-m", "uvicorn", "app:app", "--host", "localhost", "--port", str(PORT), "--log-level", "warning"]
self.proc = subprocess.Popen(cmd, cwd=self.app_dir, env=env, stdout=self.log, stderr=subprocess.STDOUT)
for _ in range(150):
try:
if requests.get(f"{BASE}/health", timeout=1).status_code == 200:
return self
except requests.RequestException:
time.sleep(0.1)
raise RuntimeError("server did not start:\n" + self.read_log())
def __exit__(self, *exc):
if os.name == "nt":
subprocess.run(["taskkill", "/PID", str(self.proc.pid), "/T", "/F"], capture_output=True)
else:
self.proc.terminate()
self.proc.wait()
self.log.close()
shutil.rmtree(self.tmp, ignore_errors=True)
def read_log(self) -> str:
with open(self.log_path, "rb") as f:
return f.read().decode("utf-8", "replace")
def log_mark(self) -> int:
return os.path.getsize(self.log_path)
def reason_since(self, mark: int) -> str:
"""The first 'rejected' line the server logged after mark, without the log prefix."""
lines = [line for line in self.read_log().encode()[mark:].decode().splitlines() if "rejected:" in line]
return lines[0].split("rejected: ", 1)[1] if lines else ""
def sql(self, query, args=()):
con = sqlite3.connect(self.db_path)
try:
rows = con.execute(query, args).fetchall()
con.commit()
return rows
finally:
con.close()
Why that detail? I measured it. The script below starts the server both ways and times requests to http://localhost:8000:
# localhost_timing.py
"""Time requests to http://localhost:8000 against a server bound to 127.0.0.1, then one bound to localhost."""
import os
import statistics
import subprocess
import sys
import tempfile
import time
import requests
from server import HERE
def timed(url, n=4):
spans = []
for _ in range(n):
start = time.perf_counter()
requests.get(url, timeout=30)
spans.append(time.perf_counter() - start)
return statistics.median(spans)
for host in ("127.0.0.1", "localhost"):
env = dict(os.environ, DB_PATH=os.path.join(tempfile.mkdtemp(), "t.db"))
cmd = [sys.executable, "-m", "uvicorn", "app:app", "--host", host, "--port", "8000", "--log-level", "warning"]
proc = subprocess.Popen(cmd, cwd=HERE, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
for _ in range(100):
try:
requests.get("http://127.0.0.1:8000/health", timeout=1)
break
except requests.RequestException:
time.sleep(0.1)
print(f"uvicorn --host {host:<9} median request to http://localhost:8000 = {timed('http://localhost:8000/health'):.2f} s")
finally:
subprocess.run(["taskkill", "/PID", str(proc.pid), "/T", "/F"], capture_output=True)
time.sleep(1)
python localhost_timing.py
uvicorn --host 127.0.0.1 median request to http://localhost:8000 = 2.07 s
uvicorn --host localhost median request to http://localhost:8000 = 0.01 s
With uvicorn on 127.0.0.1 every request to localhost waited about two seconds; with uvicorn on localhost they took hundredths of a second. I did not look inside the connection attempts, but the size of the gap is the point: my 20-test suite took 133.64 seconds before I changed the bind address and 5.15 seconds after.
before binding uvicorn to localhost: 20 passed in 133.64s
after binding uvicorn to localhost: 20 passed in 5.15s
Now the happy path. This script signs up in Edge, signs out, and signs in again, each time by running the page’s own JavaScript:
# happy_path.py
"""Sign up, sign out and sign in again in Edge, running the page's own JavaScript."""
from server import Server
from virtual import Edge
def me(person):
reply = person.call("me")["data"]
body = reply["body"]
if "passkeys" in body: # hide the random credential id prefix so the output is repeatable
body = {**body, "passkeys": [{k: v for k, v in p.items() if k != "id"} for p in body["passkeys"]]}
return reply["status"], body
with Server() as server, Edge() as edge:
alice = edge.person()
print("1. sign up ->", alice.call("signup", "[email protected]"))
print("2. who am I ->", me(alice))
print("3. sign out ->", alice.call("signout"))
print("4. who am I ->", me(alice))
print("5. sign in ->", alice.call("signin"))
print("6. who am I ->", me(alice))
print("\nwhat the server stored")
print(" users: ", server.sql("SELECT email, length(handle) FROM users"))
print(" credentials: ", server.sql("SELECT length(credential_id), length(public_key), sign_count, device_type FROM credentials"))
print(" sessions: ", server.sql("SELECT count(*) FROM sessions"))
print(" challenges: ", server.sql("SELECT count(*) FROM challenges"))
print("the virtual authenticator holds:", [(c["rpId"], c["isResidentCredential"], c["signCount"]) for c in alice.stored()])
python happy_path.py
1. sign up -> {'ok': True, 'data': {'email': '[email protected]'}}
2. who am I -> (200, {'email': '[email protected]', 'passkeys': [{'device_type': 'single_device', 'backed_up': False, 'sign_count': 1}]})
3. sign out -> {'ok': True, 'data': {'signed_out': True}}
4. who am I -> (401, {'detail': 'sign in first'})
5. sign in -> {'ok': True, 'data': {'signed_in': True}}
6. who am I -> (200, {'email': '[email protected]', 'passkeys': [{'device_type': 'single_device', 'backed_up': False, 'sign_count': 2}]})
what the server stored
users: [('[email protected]', 32)]
credentials: [(32, 42, 2, 'single_device')]
sessions: [(1,)]
challenges: [(0,)]
the virtual authenticator holds: [('localhost', True, 2)]
What to check
Line 2 shows a signed-in account with one passkey whose counter is 1. Line 4 shows 401 after sign-out, so the session really ended. Line 6 shows the same account after a sign-in that used no username, and the counter has moved to 2. At the bottom, the database holds one user with a 32-byte handle, one credential (a 32-byte ID and a 42-byte public key, which is the Ed25519 key from Step 2), one live session and zero leftover challenges: every challenge was spent. The virtual authenticator’s own store agrees: one discoverable credential for localhost whose counter is 2.
Step 7: Meet the four errors the browser reports
Before attacking the server, it helps to see the mistakes that never reach it, because the browser stops them first. This script triggers four of them with the exact settings that cause them.
# browser_errors.py
"""Four mistakes the browser itself reports, with the exact messages Edge gives."""
import json
from webauthn import generate_authentication_options, generate_registration_options, options_to_json
from webauthn.helpers.structs import AuthenticatorSelectionCriteria, ResidentKeyRequirement, UserVerificationRequirement
from virtual import SIBLING, Edge, SiblingPage
REQUIRED = AuthenticatorSelectionCriteria(
resident_key=ResidentKeyRequirement.REQUIRED, user_verification=UserVerificationRequirement.REQUIRED
)
def register(person, selection=None):
options = generate_registration_options(
rp_id="localhost", rp_name="Lab", user_name="[email protected]", authenticator_selection=selection
)
assert person.create(json.loads(options_to_json(options)), url=SIBLING + "/")["ok"]
def sign_in(person, url):
options = generate_authentication_options(rp_id="localhost", user_verification=UserVerificationRequirement.REQUIRED)
reply = person.get(json.loads(options_to_json(options)), url=url)
return "signed" if reply["ok"] else reply["error"]
with SiblingPage(), Edge() as edge:
forgetful = edge.person(start_url=SIBLING + "/")
register(forgetful) # no authenticatorSelection: the key is NOT discoverable
print("1. registered without residentKey; discoverable:", forgetful.stored()[0]["isResidentCredential"])
print(" usernameless sign-in ->", sign_in(forgetful, SIBLING + "/"))
careful = edge.person(start_url=SIBLING + "/")
register(careful, REQUIRED)
print(" same sign-in with residentKey=required ->", sign_in(careful, SIBLING + "/"))
print("2. page on http://127.0.0.1:8001 ->", sign_in(careful, "http://127.0.0.1:8001/"))
print("3. page on http://evil.localhost:8001 ->", sign_in(careful, "http://evil.localhost:8001/"))
careful.set_user_verified(False)
print("4. authenticator cannot verify the user ->", sign_in(careful, SIBLING + "/"))
python browser_errors.py
1. registered without residentKey; discoverable: False
usernameless sign-in -> NotAllowedError: The operation either timed out or was not allowed. See: https://www.w3.org/TR/webauthn-2/#sctn-privacy-considerations-client.
same sign-in with residentKey=required -> signed
2. page on http://127.0.0.1:8001 -> SecurityError: This is an invalid domain.
3. page on http://evil.localhost:8001 -> SecurityError: The relying party ID is not a registrable domain suffix of, nor equal to the current domain. Subsequently, an attempt to fetch the .well-known/webauthn resource of the claimed RP ID failed.
4. authenticator cannot verify the user -> NotAllowedError: The operation either timed out or was not allowed. See: https://www.w3.org/TR/webauthn-2/#sctn-privacy-considerations-client.
Reading the four messages
1. A key you cannot find by itself. If registration does not ask for residentKey, the credential is not discoverable (the second line prints False, read from the authenticator’s store). A sign-in with no username then fails with NotAllowedError. The message is deliberately vague, and it links to the specification’s privacy section, so check the authenticator rather than the message. The fix is the selection() function from Step 4, and the next line shows it working.
2. An IP address. On http://127.0.0.1:8001 Edge answers This is an invalid domain. for the RP ID localhost, and the same for an RP ID of 127.0.0.1 (I tried both in a separate probe). Use the name localhost in development.
3. A page that does not belong to the RP ID. On http://evil.localhost:8001 the browser refuses before your server hears anything: the RP ID “is not a registrable domain suffix of, nor equal to the current domain”. The rest of the message mentions the .well-known/webauthn file. That is a Level 3 mechanism: when a page asks for an RP ID that is not its own domain, the browser fetches that file from the RP ID to see whether it vouches for the page, and a failed fetch ends in a SecurityError. This is why a copied page on an unrelated domain cannot ask for your users’ passkeys under your RP ID, unless your own site vouches for it in that file.
4. A user who cannot be verified. After the script tells the virtual authenticator that user verification now fails (setUserVerified), a sign-in that requires verification gets the same vague NotAllowedError as case 1. The DevTools Protocol describes that switch as: “Sets whether User Verification succeeds or fails for an authenticator.”
Step 8: Attack your own server
The browser protects users from many mistakes, but your server must not rely on it. A hostile client does not have to use a browser at all. To test the server the way an attacker would, we need a client that can write any field, so here is a software passkey. It is a teaching toy: it holds a real P-256 key, builds the same bytes a real authenticator builds, and lets each test change one field at a time.
# softkey.py
"""A software passkey in under 100 lines: enough to register and sign in, and to forge any field."""
import base64
import hashlib
import json
import os
import cbor2
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
UP, UV, BE, BS, AT = 0x01, 0x04, 0x08, 0x10, 0x40 # authenticator data flag bits
def b64u(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def unb64u(text: str) -> bytes:
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
class SoftKey:
"""One passkey: a P-256 key pair, a credential id and a signature counter.
synced=True behaves like a passkey synced between devices: it reports a counter of 0 forever.
synced=False behaves like a device-bound key: the counter goes up on every sign-in.
"""
def __init__(self, rp_id: str = "localhost", synced: bool = True):
self.rp_id = rp_id
self.synced = synced
self.private_key = ec.generate_private_key(ec.SECP256R1())
self.credential_id = os.urandom(32)
self.user_handle = b""
self.sign_count = 0
def clone(self) -> "SoftKey":
"""A copy of the key as it is right now, the way a stolen backup or a cloned token would be."""
twin = SoftKey(self.rp_id, self.synced)
twin.private_key, twin.credential_id = self.private_key, self.credential_id
twin.user_handle, twin.sign_count = self.user_handle, self.sign_count
return twin
def _cose_public_key(self) -> bytes:
numbers = self.private_key.public_key().public_numbers()
key = {1: 2, 3: -7, -1: 1, -2: numbers.x.to_bytes(32, "big"), -3: numbers.y.to_bytes(32, "big")}
return cbor2.dumps(key) # kty EC2, alg ES256, crv P-256, x, y
def _flags(self, uv: bool, attested: bool) -> int:
flags = UP | (UV if uv else 0) | (BE | BS if self.synced else 0)
return flags | (AT if attested else 0)
def _client_data(self, kind: str, options: dict, origin: str) -> bytes:
data = {"type": kind, "challenge": options["challenge"], "origin": origin, "crossOrigin": False}
return json.dumps(data).encode()
def register(self, options: dict, origin: str, *, uv: bool = True) -> dict:
"""Answer the JSON from /register/options the way navigator.credentials.create() would."""
self.user_handle = unb64u(options["user"]["id"])
rp_hash = hashlib.sha256(self.rp_id.encode()).digest()
auth_data = (
rp_hash + bytes([self._flags(uv, attested=True)]) + (0).to_bytes(4, "big")
+ bytes(16) # AAGUID: all zeros means "no model information"
+ len(self.credential_id).to_bytes(2, "big") + self.credential_id + self._cose_public_key()
)
attestation = cbor2.dumps({"fmt": "none", "attStmt": {}, "authData": auth_data})
client_data = self._client_data("webauthn.create", options, origin)
return {
"id": b64u(self.credential_id),
"rawId": b64u(self.credential_id),
"type": "public-key",
"response": {"clientDataJSON": b64u(client_data), "attestationObject": b64u(attestation)},
"clientExtensionResults": {},
}
def sign_in(self, options: dict, origin: str, *, uv: bool = True, rp_id=None, user_handle="own", count=None) -> dict:
"""Answer /login/options; the keyword arguments let a test forge one field at a time."""
if count is None:
self.sign_count = 0 if self.synced else self.sign_count + 1
count = self.sign_count
rp_hash = hashlib.sha256((rp_id or self.rp_id).encode()).digest()
auth_data = rp_hash + bytes([self._flags(uv, attested=False)]) + count.to_bytes(4, "big")
client_data = self._client_data("webauthn.get", options, origin)
signature = self.private_key.sign(auth_data + hashlib.sha256(client_data).digest(), ec.ECDSA(hashes.SHA256()))
response = {"clientDataJSON": b64u(client_data), "authenticatorData": b64u(auth_data), "signature": b64u(signature)}
handle = self.user_handle if user_handle == "own" else user_handle
if handle is not None:
response["userHandle"] = b64u(handle)
return {
"id": b64u(self.credential_id),
"rawId": b64u(self.credential_id),
"type": "public-key",
"response": response,
"clientExtensionResults": {},
}
With synced=True it behaves like a passkey copied between devices and keeps its counter at zero, as the specification allows for authenticators without a counter. With synced=False it behaves like a device-bound key whose counter goes up on every sign-in. (Whether a given provider’s synced passkeys report zero is something to check against your own users; I model the zero case because the specification allows it.) The attack catalogue below uses it, and uses Edge for the two attacks that need a real browser.
# attacks.py
"""Attack catalogue: every function tries one break-in against the running app and reports if it worked."""
import itertools
from dataclasses import dataclass
import requests
from virtual import BASE, SIBLING
from softkey import SoftKey, b64u, unb64u
_numbers = itertools.count(1)
@dataclass
class Result:
status: int # HTTP status of the request that decided the attack
breached: bool # True when the attacker got what they wanted
note: str = ""
@dataclass
class World:
server: object # labkit.Server: gives the attacks the database and the log
edge: object = None # labkit.Edge: only the browser attacks need it
def new_address() -> str:
return f"user{next(_numbers)}@example.test"
def sign_up(key: SoftKey, address=None):
"""Create an account with a soft passkey. Returns (session, address, last reply)."""
address = address or new_address()
session = requests.Session()
reply = session.post(f"{BASE}/register/options", json={"email": address})
if reply.status_code == 200:
reply = session.post(f"{BASE}/register/verify", json={"credential": key.register(reply.json(), BASE)})
return session, address, reply
def sign_in(key: SoftKey, session=None, origin=BASE, headers=None, **forge):
"""Run one sign-in ceremony. Returns (session, reply, credential)."""
session = session or requests.Session()
options = session.post(f"{BASE}/login/options").json()
credential = key.sign_in(options, origin, **forge)
reply = session.post(f"{BASE}/login/verify", json={"credential": credential}, headers=headers)
return session, reply, credential
def who(session):
reply = session.get(f"{BASE}/me")
return reply.json().get("email") if reply.status_code == 200 else None
def enrolled(key: SoftKey) -> SoftKey:
sign_up(key)
return key
def replay_same_ceremony(w):
"""Send a captured sign-in a second time inside the same ceremony."""
session, first, credential = sign_in(enrolled(SoftKey()))
assert first.status_code == 200
again = session.post(f"{BASE}/login/verify", json={"credential": credential})
return Result(again.status_code, again.status_code == 200)
def replay_fresh_ceremony(w):
"""Ask for a new challenge, then send the old sign-in against it."""
_, first, credential = sign_in(enrolled(SoftKey()))
assert first.status_code == 200
attacker = requests.Session()
attacker.post(f"{BASE}/login/options")
again = attacker.post(f"{BASE}/login/verify", json={"credential": credential})
return Result(again.status_code, again.status_code == 200)
def expired_challenge(w):
"""Answer a challenge after its two minutes are up."""
key = enrolled(SoftKey())
session = requests.Session()
options = session.post(f"{BASE}/login/options").json()
w.server.sql("UPDATE challenges SET expires_at = 0")
reply = session.post(f"{BASE}/login/verify", json={"credential": key.sign_in(options, BASE)})
return Result(reply.status_code, reply.status_code == 200)
def other_origin_relay(w):
"""A page on another origin of the same site signs a real challenge; a relay forwards it."""
_, reply, _ = sign_in(enrolled(SoftKey()), origin=SIBLING, headers={"Origin": SIBLING})
return Result(reply.status_code, reply.status_code == 200)
def wrong_rp_id(w):
"""Sign for a different relying party ID than the one the server expects."""
_, reply, _ = sign_in(enrolled(SoftKey()), rp_id="evil.example")
return Result(reply.status_code, reply.status_code == 200)
def user_verification_off(w):
"""Sign without the user-verified flag (no PIN, no fingerprint) although the server asked for it."""
_, reply, _ = sign_in(enrolled(SoftKey()), uv=False)
return Result(reply.status_code, reply.status_code == 200)
def user_handle_swap(w):
"""Sign in with the attacker's own passkey but put the victim's user handle in the response."""
victim_address = new_address()
sign_up(SoftKey(), victim_address)
victim_handle = w.server.sql("SELECT handle FROM users WHERE email = ?", (victim_address,))[0][0]
session, reply, _ = sign_in(enrolled(SoftKey()), user_handle=victim_handle)
return Result(reply.status_code, reply.status_code == 200 and who(session) == victim_address)
def signup_takeover(w):
"""Sign up with a victim's email address and a new passkey, then sign in with that passkey."""
_, victim_address, _ = sign_up(SoftKey())
attacker_key = SoftKey()
_, _, reply = sign_up(attacker_key, victim_address)
session, login, _ = sign_in(attacker_key)
return Result(reply.status_code, login.status_code == 200 and who(session) == victim_address)
def counter_rollback(w):
"""Use a cloned device-bound key whose counter is behind the server's record."""
key = enrolled(SoftKey(synced=False))
sign_in(key)
clone = key.clone()
sign_in(key)
sign_in(key)
_, reply, _ = sign_in(clone)
return Result(reply.status_code, reply.status_code == 200)
def tampered_signature(w):
"""Flip one bit in a genuine signature."""
key = enrolled(SoftKey())
session = requests.Session()
options = session.post(f"{BASE}/login/options").json()
credential = key.sign_in(options, BASE)
signature = bytearray(unb64u(credential["response"]["signature"]))
signature[-1] ^= 1
credential["response"]["signature"] = b64u(bytes(signature))
reply = session.post(f"{BASE}/login/verify", json={"credential": credential})
return Result(reply.status_code, reply.status_code == 200)
def add_passkey_without_session(w):
"""Ask to add a passkey without being signed in."""
reply = requests.post(f"{BASE}/passkeys/options")
return Result(reply.status_code, reply.status_code == 200, reply.json().get("detail", ""))
def garbage_never_500(w):
"""Post malformed credentials to both verify endpoints; any 5xx counts as a failure."""
key = enrolled(SoftKey())
known = {"rawId": b64u(key.credential_id), "response": {"userHandle": b64u(key.user_handle), "clientDataJSON": 5}}
bodies = [{"credential": {}}, {"credential": {"rawId": 5, "response": {"userHandle": 7}}},
{"credential": {"rawId": "AAAA", "response": {"userHandle": "AAAA"}}}, {"credential": known},
{"credential": "text"}, {"nope": 1}]
worst = 0
for path, options_path, extra in (("/login/verify", "/login/options", {}),
("/register/verify", "/register/options", {"email": new_address()})):
for body in bodies:
session = requests.Session()
session.post(f"{BASE}{options_path}", json=extra or None)
worst = max(worst, session.post(f"{BASE}{path}", json=body).status_code)
raw = requests.post(f"{BASE}/login/verify", data=b"{", headers={"Content-Type": "application/json"})
worst = max(worst, raw.status_code)
return Result(worst, worst >= 500, "highest status seen")
def other_origin_in_edge(w):
"""The same relay, but Edge itself signs on a real page at http://localhost:8001."""
person = w.edge.person()
session = requests.Session()
options = session.post(f"{BASE}/register/options", json={"email": new_address()}).json()
created = person.create(options)
assert created["ok"], created
assert session.post(f"{BASE}/register/verify", json={"credential": created["data"]}).status_code == 201
attacker = requests.Session()
options = attacker.post(f"{BASE}/login/options").json()
signed = person.get(options, url=SIBLING + "/")
assert signed["ok"], signed
reply = attacker.post(f"{BASE}/login/verify", json={"credential": signed["data"]}, headers={"Origin": SIBLING})
return Result(reply.status_code, reply.status_code == 200)
ATTACKS = {
"replay_same_ceremony": replay_same_ceremony,
"replay_fresh_ceremony": replay_fresh_ceremony,
"expired_challenge": expired_challenge,
"other_origin_relay": other_origin_relay,
"other_origin_in_edge": other_origin_in_edge,
"wrong_rp_id": wrong_rp_id,
"user_verification_off": user_verification_off,
"user_handle_swap": user_handle_swap,
"signup_takeover": signup_takeover,
"counter_rollback": counter_rollback,
"tampered_signature": tampered_signature,
"add_passkey_without_session": add_passkey_without_session,
"garbage_never_500": garbage_never_500,
}
Run the whole catalogue against the app:
# attack_matrix.py
"""Throw every attack in the catalogue at the running app and print what the server did."""
import re
from attacks import ATTACKS, World
from server import Server
from virtual import Edge, SiblingPage
with Server() as server, SiblingPage(), Edge() as edge:
world = World(server, edge)
print(f"{'#':>2} {'attack':<28}{'HTTP':>4} {'result':<9}why the server said no")
for number, (name, attack) in enumerate(ATTACKS.items(), 1):
mark = server.log_mark()
result = attack(world)
reason = re.sub(r"^.*?\((.*)\)$", r"\1", server.reason_since(mark)) or result.note
verdict = "BREACHED" if result.breached else "blocked"
print(f"{number:>2} {name:<28}{result.status:>4} {verdict:<9}{reason}")
python attack_matrix.py
# attack HTTP result why the server said no
1 replay_same_ceremony 401 blocked ValueError: challenge missing, expired or used
2 replay_fresh_ceremony 401 blocked InvalidAuthenticationResponse: Client data challenge was not expected challenge
3 expired_challenge 401 blocked ValueError: challenge missing, expired or used
4 other_origin_relay 401 blocked InvalidAuthenticationResponse: Unexpected client data origin "http://localhost:8001", expected one of ['http://localhost:8000']
5 other_origin_in_edge 401 blocked InvalidAuthenticationResponse: Unexpected client data origin "http://localhost:8001", expected one of ['http://localhost:8000']
6 wrong_rp_id 401 blocked InvalidAuthenticationResponse: Unexpected RP ID hash
7 user_verification_off 401 blocked InvalidAuthenticationResponse: User verification is required but user was not verified during authentication
8 user_handle_swap 401 blocked ValueError: no such passkey for that user handle
9 signup_takeover 409 blocked that email already has an account; sign in and add a passkey instead
10 counter_rollback 401 blocked InvalidAuthenticationResponse: Response sign count of 2 was not greater than current count of 3
11 tampered_signature 401 blocked InvalidAuthenticationResponse: Could not verify authentication signature
12 add_passkey_without_session 401 blocked sign in first
13 garbage_never_500 422 blocked KeyError: 'rawId'
Why each attack fails
Attacks 1 to 3: replays
An attacker who copies a valid sign-in cannot reuse it. Attack 1 sends it twice in the same ceremony and meets the single-use challenge from Step 3. Attack 2 asks for a brand-new challenge and sends the old answer, which carries the old challenge. Attack 3 lets the two minutes run out (the test simulates that by moving the expiry into the past). With a synced passkey the counter stays at zero, so it cannot help here: the one-time challenge is the only wall, which is why I test it with the zero-counter key.
Attacks 4 to 6: the wrong place
Attack 5 is the most interesting one. Edge signs a real challenge on a page served from http://localhost:8001, which shares the RP ID localhost with the app on port 8000, and the browser allows it, because the RP ID matches. Only your server can say that the page was the wrong one, by comparing the origin in clientDataJSON with its own list. The specification is blunt about it: “The Relying Party MUST NOT accept unexpected values of origin, as doing so could allow a malicious website to obtain valid credentials.” It adds that origin validation “serves as an additional layer of protection in case a faulty authenticator fails to enforce credential scope”, and for a site served at one address it says the server “SHOULD require origin to exactly equal https://example.org”. The specification sends you to its discussion of “potentially malicious subdomains” for this case. In my reading, a forgotten subdomain or a page with an injected script is the real-world version of the page on port 8001. Attack 4 repeats the idea without a browser, and attack 6 signs for a different RP ID, which py_webauthn rejects as Unexpected RP ID hash.
Attacks 7 and 8: checks the client can skip
Attack 7 sends a response with the user-verified flag cleared. A server that only asked for verification, and did not require it, would accept it. Attack 8 is the user handle swap. Mallory has a genuine passkey and puts Alice’s handle in the response. The handle is not protected by the signature: the specification says the signature is checked over “the binary concatenation of authData and hash”, and userHandle is in neither. So the server must check that the credential belongs to the user the handle names, which is exactly the join from Step 4.4. This is the same class of mistake that freeCodeCamp’s guide names “Looking Up a Credential Without Checking Its Owner”.
Attacks 9 and 10: takeover and cloning
Attack 9 signs up with a victim’s email address, hoping the server will quietly add the attacker’s passkey to the victim’s account (the guide calls it “Letting Sign Up Add Passkeys to Existing Accounts”). The server stops at the 409 from Step 4.2. Attack 10 copies a device-bound key, lets the owner keep signing in, and then signs in with the stale copy. The server’s record says 3 and the copy answers 2. The specification calls that “a signal, but not proof”, because a counter can also fall behind when two responses are processed out of order. It leaves the choice to you: “Whether the Relying Party updates credentialRecord.signCount below in this case, or not, or fails the authentication ceremony or not, is Relying Party-specific.” This app rejects, and it logs the reason.
Attacks 11 to 13: tampering and junk
A flipped bit in a signature fails verification. Asking to add a passkey without a session gets 401. And malformed input never produces a 500: the thirteenth attack posts twelve malformed bodies to the two verify endpoints (missing fields, wrong types, a real credential ID with a number where the client data should be) plus one body that is not valid JSON, and the highest status is a 422 from FastAPI’s own validation.
Step 9: Pin the defenses with tests
Each attack becomes a test that fails if the attack ever works, and a few more tests cover the happy paths: a real Edge sign-up, sign-out and sign-in; the software key against the server; four zero-counter sign-ins in a row (so a future change cannot start rejecting synced passkeys); adding a second passkey; cookie flags and session rotation; identical failure messages; and the options themselves.
# test_passkeys.py
"""Run with: pytest -v (it starts the app, a sibling origin and Edge by itself)."""
import pytest
import requests
from attacks import ATTACKS, World, new_address, sign_in, sign_up, who
from server import Server
from softkey import SoftKey, b64u, unb64u
from virtual import BASE, Edge, SiblingPage
@pytest.fixture(scope="session")
def world():
with Server() as server, SiblingPage(), Edge() as edge:
yield World(server, edge)
@pytest.mark.parametrize("name", list(ATTACKS))
def test_attack_is_blocked(world, name):
result = ATTACKS[name](world)
assert not result.breached, f"{name}: HTTP {result.status} {result.note}"
def test_edge_sign_up_sign_out_sign_in(world):
person = world.edge.person()
address = new_address()
assert person.call("signup", address)["ok"]
first = person.call("me")["data"]
assert first["status"] == 200 and first["body"]["email"] == address
assert person.call("signout")["ok"]
assert person.call("me")["data"]["status"] == 401
signed_in = person.call("signin")
assert signed_in["ok"], signed_in
second = person.call("me")["data"]["body"]
assert second["email"] == address
assert second["passkeys"][0]["sign_count"] > first["body"]["passkeys"][0]["sign_count"]
def test_soft_key_matches_what_the_server_expects(world):
key = SoftKey()
session, address, reply = sign_up(key)
assert reply.status_code == 201 and who(session) == address
session, reply, _ = sign_in(key)
assert reply.status_code == 200 and who(session) == address
def test_synced_passkey_with_counter_zero_keeps_working(world):
key = SoftKey(synced=True)
sign_up(key)
assert [sign_in(key)[1].status_code for _ in range(4)] == [200, 200, 200, 200]
def test_add_second_passkey(world):
first, second = SoftKey(), SoftKey()
session, address, _ = sign_up(first)
options = session.post(f"{BASE}/passkeys/options").json()
assert [item["id"] for item in options["excludeCredentials"]] == [b64u(first.credential_id)]
added = session.post(f"{BASE}/passkeys/verify", json={"credential": second.register(options, BASE)})
assert added.status_code == 201
assert len(session.get(f"{BASE}/me").json()["passkeys"]) == 2
other, reply, _ = sign_in(second)
assert reply.status_code == 200 and who(other) == address
def test_session_cookie_flags_and_rotation(world):
key = SoftKey()
sign_up(key)
session, reply, _ = sign_in(key)
cookie = reply.headers["set-cookie"].lower()
assert "httponly" in cookie and "samesite=lax" in cookie
old_token = session.cookies["session"]
_, again, _ = sign_in(key, session=session)
assert again.status_code == 200 and session.cookies["session"] != old_token
assert requests.get(f"{BASE}/me", cookies={"session": old_token}).status_code == 401
def test_every_failure_looks_the_same_to_the_client(world):
key = SoftKey()
sign_up(key)
_, _, spent = sign_in(key) # a genuine sign-in whose challenge is already used up
session = requests.Session()
session.post(f"{BASE}/login/options")
replayed = session.post(f"{BASE}/login/verify", json={"credential": spent})
stranger = sign_in(SoftKey())[1]
wrong_origin = sign_in(key, origin="http://localhost:9")[1]
replies = (replayed, stranger, wrong_origin)
assert {reply.status_code for reply in replies} == {401}
assert {reply.text for reply in replies} == {'{"detail":"sign-in failed"}'}
def test_options_ask_for_discoverable_credentials_and_user_verification(world):
options = requests.post(f"{BASE}/register/options", json={"email": new_address()}).json()
assert options["authenticatorSelection"]["residentKey"] == "required"
assert options["authenticatorSelection"]["userVerification"] == "required"
assert len(unb64u(options["user"]["id"])) == 32
login = requests.post(f"{BASE}/login/options").json()
assert login["userVerification"] == "required" and not login.get("allowCredentials")
python -m pytest -v
=== test session starts ===
collecting ... collected 20 items
test_passkeys.py::test_attack_is_blocked[replay_same_ceremony] PASSED [ 5%]
test_passkeys.py::test_attack_is_blocked[replay_fresh_ceremony] PASSED [ 10%]
test_passkeys.py::test_attack_is_blocked[expired_challenge] PASSED [ 15%]
test_passkeys.py::test_attack_is_blocked[other_origin_relay] PASSED [ 20%]
test_passkeys.py::test_attack_is_blocked[other_origin_in_edge] PASSED [ 25%]
test_passkeys.py::test_attack_is_blocked[wrong_rp_id] PASSED [ 30%]
test_passkeys.py::test_attack_is_blocked[user_verification_off] PASSED [ 35%]
test_passkeys.py::test_attack_is_blocked[user_handle_swap] PASSED [ 40%]
test_passkeys.py::test_attack_is_blocked[signup_takeover] PASSED [ 45%]
test_passkeys.py::test_attack_is_blocked[counter_rollback] PASSED [ 50%]
test_passkeys.py::test_attack_is_blocked[tampered_signature] PASSED [ 55%]
test_passkeys.py::test_attack_is_blocked[add_passkey_without_session] PASSED [ 60%]
test_passkeys.py::test_attack_is_blocked[garbage_never_500] PASSED [ 65%]
test_passkeys.py::test_edge_sign_up_sign_out_sign_in PASSED [ 70%]
test_passkeys.py::test_soft_key_matches_what_the_server_expects PASSED [ 75%]
test_passkeys.py::test_synced_passkey_with_counter_zero_keeps_working PASSED [ 80%]
test_passkeys.py::test_add_second_passkey PASSED [ 85%]
test_passkeys.py::test_session_cookie_flags_and_rotation PASSED [ 90%]
test_passkeys.py::test_every_failure_looks_the_same_to_the_client PASSED [ 95%]
test_passkeys.py::test_options_ask_for_discoverable_credentials_and_user_verification PASSED [100%]
=== 20 passed in 5.05s ===
Twenty tests pass in about five seconds, including the ones that drive Edge. The test test_every_failure_looks_the_same_to_the_client pins the generic message: a replay, an unknown passkey and a wrong origin all return exactly {"detail":"sign-in failed"} with status 401.
Step 10: Prove the tests bite
A test that has never failed has not proven anything. So the next script copies the app once per bug (ten copies, plus an untouched baseline), plants one realistic bug in each, and runs the same suite against it. Every bug is an exact text swap, so you can read what changed.
# mutations.py
"""Plant one realistic bug at a time in a copy of the app and see which tests notice."""
import os
import re
import shutil
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "mutants")
MUTANTS = {
"challenge_not_consumed": [("db.py",
'"DELETE FROM challenges WHERE ceremony_id = ? AND kind = ? RETURNING *", (ceremony_id, kind)',
'"SELECT * FROM challenges WHERE ceremony_id = ? AND kind = ?", (ceremony_id, kind)', 1)],
"challenge_from_client": [("app.py",
'expected_challenge=ceremony["challenge"],\n expected_rp_id=RP_ID,\n expected_origin=ORIGINS,\n credential_public_key=',
'expected_challenge=base64url_to_bytes(__import__("json").loads(base64url_to_bytes(body.credential["response"]["clientDataJSON"]))["challenge"]),\n expected_rp_id=RP_ID,\n expected_origin=ORIGINS,\n credential_public_key=', 1)],
"challenge_never_expires": [("db.py",
'if row is None or row["expires_at"] < time.time():', 'if row is None:', 1)],
"origin_from_request": [("app.py",
'expected_origin=ORIGINS,', 'expected_origin=request.headers.get("origin", ORIGINS[0]),', 3)],
"user_verification_not_required": [("app.py",
'require_user_verification=True,', 'require_user_verification=False,', 3)],
"credential_owner_not_checked": [
("app.py",
' stored = con.execute(\n "SELECT c.* FROM credentials c JOIN users u ON u.id = c.user_id"\n " WHERE c.credential_id = ? AND u.handle = ?",\n (credential_id, user_handle),\n ).fetchone()\n if stored is None:',
' stored = con.execute("SELECT * FROM credentials WHERE credential_id = ?", (credential_id,)).fetchone()\n owner = con.execute("SELECT id FROM users WHERE handle = ?", (user_handle,)).fetchone()\n if stored is None or owner is None:', 1),
("app.py", 'start_session(con, response, stored["user_id"],', 'start_session(con, response, owner["id"],', 1)],
"sign_up_attaches_to_existing_email": [
("app.py",
""" handle = secrets.token_bytes(32)
options = generate_registration_options(
rp_id=RP_ID,
rp_name=RP_NAME,
user_id=handle,
user_name=body.email,
user_display_name=body.email,
authenticator_selection=selection(),
)
with closing(db.connect()) as con:
if con.execute("SELECT 1 FROM users WHERE email = ?", (body.email,)).fetchone():
reject(409, "that email already has an account; sign in and add a passkey instead", "register")
""",
""" handle = secrets.token_bytes(32)
with closing(db.connect()) as con:
known = con.execute("SELECT handle FROM users WHERE email = ?", (body.email,)).fetchone()
handle = known["handle"] if known else handle
options = generate_registration_options(
rp_id=RP_ID,
rp_name=RP_NAME,
user_id=handle,
user_name=body.email,
user_display_name=body.email,
authenticator_selection=selection(),
)
""", 1),
("app.py",
""" cursor = con.execute(
"INSERT INTO users (handle, email, created_at) VALUES (?, ?, ?)",
(ceremony["user_handle"], ceremony["email"], time.time()),
)
user_id = cursor.lastrowid
""",
""" con.execute(
"INSERT OR IGNORE INTO users (handle, email, created_at) VALUES (?, ?, ?)",
(ceremony["user_handle"], ceremony["email"], time.time()),
)
user_id = con.execute("SELECT id FROM users WHERE email = ?", (ceremony["email"],)).fetchone()["id"]
""", 1)],
"sign_count_ignored": [("app.py",
'credential_current_sign_count=stored["sign_count"],', 'credential_current_sign_count=0,', 1)],
"malformed_input_uncaught": [("app.py",
'VERIFY_ERRORS = (WebAuthnException, ValueError, KeyError, TypeError, AttributeError)',
'VERIFY_ERRORS = (WebAuthnException,)', 1)],
"resident_key_forgotten": [("app.py",
'resident_key=ResidentKeyRequirement.REQUIRED,', 'resident_key=ResidentKeyRequirement.DISCOURAGED,', 1)],
}
def run(name, edits):
dest = os.path.join(OUT, name)
shutil.rmtree(dest, ignore_errors=True)
os.makedirs(dest)
for filename in ("app.py", "db.py"):
shutil.copy(os.path.join(HERE, filename), dest)
shutil.copytree(os.path.join(HERE, "static"), os.path.join(dest, "static"))
for filename, old, new, count in edits:
path = os.path.join(dest, filename)
with open(path, encoding="utf-8") as f:
text = f.read()
assert text.count(old) == count, (name, filename, text.count(old))
with open(path, "w", encoding="utf-8", newline="\n") as f:
f.write(text.replace(old, new))
env = dict(os.environ, APP_DIR=dest, COLUMNS="200")
cmd = [sys.executable, "-m", "pytest", "-q", "-p", "no:cacheprovider", "--tb=no", "-rfE", "test_passkeys.py"]
done = subprocess.run(cmd, cwd=HERE, env=env, capture_output=True, text=True)
failed = re.findall(r"^(?:FAILED|ERROR) test_passkeys\.py::(\S+)", done.stdout, re.M)
return [re.sub(r"^test_attack_is_blocked\[(.*)\]$", r"\1", item) for item in failed], done.stdout.strip().splitlines()[-1]
if __name__ == "__main__":
only = sys.argv[1:]
chosen = {k: MUTANTS.get(k, []) for k in only} if only else {"baseline": []} | MUTANTS
for name, edits in chosen.items():
failed, summary = run(name, edits)
print(f"{name:<36}{len(failed):>2} failing | {', '.join(failed) or '-'}", flush=True)
python mutations.py
This takes a few minutes because every copy starts its own server and browser. The baseline copy fails nothing, and each planted bug fails the tests that guard it:
baseline 0 failing | -
challenge_not_consumed 1 failing | replay_same_ceremony
challenge_from_client 2 failing | replay_fresh_ceremony, test_every_failure_looks_the_same_to_the_client
challenge_never_expires 1 failing | expired_challenge
origin_from_request 2 failing | other_origin_relay, other_origin_in_edge
user_verification_not_required 1 failing | user_verification_off
credential_owner_not_checked 1 failing | user_handle_swap
sign_up_attaches_to_existing_email 1 failing | signup_takeover
sign_count_ignored 1 failing | counter_rollback
malformed_input_uncaught 1 failing | garbage_never_500
resident_key_forgotten 3 failing | other_origin_in_edge, test_edge_sign_up_sign_out_sign_in, test_options_ask_for_discoverable_credentials_and_user_verification
| Planted bug | What changed | Caught by |
|---|---|---|
challenge_not_consumed |
the challenge is read, not deleted | replay_same_ceremony |
challenge_from_client |
the expected challenge is copied from the client’s own data | replay_fresh_ceremony and the identical-failures test |
challenge_never_expires |
no expiry check | expired_challenge |
origin_from_request |
the expected origin is read from the request’s Origin header | other_origin_relay, other_origin_in_edge |
user_verification_not_required |
require_user_verification=False |
user_verification_off |
credential_owner_not_checked |
credential found by ID alone, user taken from the client’s handle | user_handle_swap |
sign_up_attaches_to_existing_email |
sign-up reuses the existing account and handle | signup_takeover |
sign_count_ignored |
the stored counter is replaced by 0 | counter_rollback |
malformed_input_uncaught |
only the library’s own exception is caught | garbage_never_500 |
resident_key_forgotten |
discoverable credentials are no longer required | the Edge sign-up test, other_origin_in_edge and the options test |
A mutant that survived, and what it taught me
My first version of the sign-up bug did not fail a single test. It removed the 409 and let the credential attach to the existing account, but the attacker still could not sign in as the victim, because the sign-in join from Step 4.4 compares user handles, and the attacker’s authenticator held a fresh handle, not the victim’s. A surviving mutant means either a missing test or a second defense. Here it was the second wall. The real bug is a sign-up that also reuses the victim’s stored handle when it builds the options, so I rewrote the mutant that way and signup_takeover caught it. The lesson for your own tests: keep the first wall (the 409) and the second wall (the handle join), and write each mutant to break only one of them at a time.
Step 11: Before you go to production
Use HTTPS, the right RP ID and an exact origin list
Set COOKIE_SECURE=1 so session cookies travel only over HTTPS, serve the app over HTTPS, and set RP_ID and ORIGINS to your real domain. Choose the RP ID with care: a broad one such as example.com lets every page on every subdomain ask for your users’ passkeys, and Step 8 showed what stops a rogue sibling page: your exact origin list.
Verify email ownership
This lab starts a session as soon as the passkey is created, without proving that the person owns the email address. In production, verify the address before the account is usable, otherwise anyone can register an address they do not control. Note also that the 409 at sign-up tells a visitor which addresses already have an account; decide whether that trade-off is acceptable for your product.
Plan for recovery and for more than one passkey
A passkey on a lost phone is a lost way in. Encourage a second passkey with the add flow from Step 4.3, keep a tested recovery path, and do not remove an existing sign-in method until the new one has a recovery story. Track backed_up and device_type, which the lab stores, if you want to nudge people who only have a device-bound key.
Decide what a counter rollback means for you
This app rejects a sign-in whose counter has not gone up. That is the strict reading. The specification says that “Relying Parties should evaluate their own operational characteristics and incorporate this information into their risk scoring”, so you might instead accept, flag the account and alert. Whichever you choose, keep the equal-zero case working, which the zero-counter test guards.
Add rate limits and the usual web protections
The options endpoints are public and cheap to call, so rate-limit them. The session cookie is HttpOnly and SameSite=Lax (a test checks both flags), but read the existing tutorials on hardening a FastAPI rate limiter, CSRF protection and CORS before you ship.
Verify the whole lab end to end
This last script runs every step script and the test suite and saves each output. A healthy run shows six lines like these, each with exit code 0:
# run_all.py
"""Run every step script and the test suite, saving each output under out/."""
import os
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
SCRIPTS = ["localhost_timing", "inspect_registration", "happy_path", "browser_errors", "attack_matrix"]
env = dict(os.environ, PYTHONIOENCODING="utf-8", COLUMNS="170")
os.makedirs(os.path.join(HERE, "out"), exist_ok=True)
def save(name, command):
done = subprocess.run(command, cwd=HERE, env=env, capture_output=True, text=True, encoding="utf-8")
with open(os.path.join(HERE, "out", f"{name}.txt"), "w", encoding="utf-8", newline="\n") as f:
f.write(done.stdout + done.stderr)
print(f"{name}: exit {done.returncode}, {len(done.stdout.splitlines())} lines", flush=True)
for script in SCRIPTS:
save(script, [sys.executable, f"{script}.py"])
save("pytest", [sys.executable, "-m", "pytest", "-v", "--no-header", "-p", "no:cacheprovider", "test_passkeys.py"])
python run_all.py
localhost_timing: exit 0, 2 lines
inspect_registration: exit 0, 24 lines
happy_path: exit 0, 13 lines
browser_errors: exit 0, 6 lines
attack_matrix: exit 0, 14 lines
pytest: exit 0, 25 lines
If any exit code is not 0, open out/<name>.txt for that script. The most common causes are a port that is still in use (8000 or 8001), an old uvicorn process, or a browser channel that is not installed.
Where to go next
- Conditional UI. Let the browser offer passkeys in the username field’s autofill menu instead of a button. It builds on the same two endpoints.
- Attestation. We ask for
none. If you ever need to know which authenticator model created a key, study the attestation formats py_webauthn can verify. - A passkey management page. List, rename and delete passkeys, and refuse to delete the last one.
- Related tutorials on this site. If you still hold passwords during a migration, hash them with bcrypt and Argon2 and keep the reset flow safe; add TOTP as a fallback; track devices with session revocation; compare the ownership check with IDOR and BOLA; and see the same signed-data discipline in webhook signature verification.
Sources
- Web Authentication: An API for accessing Public Key Credentials, Level 3 (W3C Recommendation, 25 August 2026): sections 6.1.1, 7.1, 7.2 and 13.5.9, RP ID, user handle and signature counter text quoted above.
- passkeys.dev terms: discoverable credential, device-bound and synced passkeys.
- py_webauthn, its changelog and its PyPI page (version 3.0.1).
- MDN: parseCreationOptionsFromJSON(), toJSON() and secure contexts; browser support from the browser-compat-data project.
- Chrome DevTools Protocol, WebAuthn domain, and Playwright’s CDPSession and browsers documentation.
- RFC 9053 (COSE algorithm and key type numbers) and the SQLite RETURNING documentation.
- How to Build Passkey Authentication with WebAuthn, Next.js and NestJS on freeCodeCamp, the guide that suggested the topic.








No Comment! Be the first one.