Passwords, second factors and passkeys
A login is a claim followed by evidence. Someone types a username, which is the claim, and then presents something only the real account holder should have. For most of the web’s history that evidence was a password. Since then the industry has added one-time codes, text messages, push prompts and, most recently, passkeys.
These are often lumped together as “MFA”, as if any second factor were as good as any other. They aren’t. A one-time code and a passkey both count as a second factor, but a phishing page can steal the first and can’t do anything with the second. The difference comes down to one property: whether the evidence is bound to the site that asked for it.
What follows takes each kind of evidence in turn. The one-time code algorithms are built from scratch and checked against their RFC test vectors, a phishing relay runs against a local login page, and a WebAuthn ceremony runs in a real Chrome. Everything shown ran on an Apple M3 Pro with Python 3.12 and Chrome 157. What happens after the login, sessions and tokens, is in the essay on JWTs, OAuth and Zero Trust.
Passwords, briefly
NIST’s SP 800-63B Revision 4, finalized in 2025, reads differently from most corporate password policies. A password used on its own must be at least 15 characters, or at least 8 when it’s one part of multi-factor login, and verifiers should allow at least 64. Composition rules such as “one digit and one symbol” are forbidden, and so is forced periodic rotation: “Verifiers and CSPs SHALL NOT require subscribers to change passwords periodically.” A change is forced only on evidence of compromise. Both push people toward predictable passwords, Summer2026! followed by Autumn2026!, and length helps more than variety. How to store them is covered, with measurements, in the System Design security chapter.
What NIST requires instead is a blocklist. A new password must be compared, as a whole, against “known commonly used, expected, or compromised passwords” and rejected with a reason if found. The list only needs to stop the guesses an attacker could make before the rate limit, which NIST caps at 100 consecutive failures per authenticator.
The largest public list of compromised passwords is Pwned Passwords, and its API is designed so the service never learns the password being checked. The client hashes the password with SHA-1, sends only the first five hex characters of the hash, and receives every known hash suffix that begins with that prefix, each with a count. The comparison happens on the client. The API is free and needs no key.
The split looks like this, computed locally without contacting the service:
import hashlib
digest = hashlib.sha1("correct horse battery staple".encode()).hexdigest().upper()
prefix, suffix = digest[:5], digest[5:]
print("SHA-1 ", digest)
print("sent ", f"GET https://api.pwnedpasswords.com/range/{prefix}")
print("kept local", suffix)
print("prefixes ", f"{16 ** 5:,}")
SHA-1 ABF7AAD6438836DBE526AA231ABDE2D0EEF74D42
sent GET https://api.pwnedpasswords.com/range/ABF7A
kept local AD6438836DBE526AA231ABDE2D0EEF74D42
prefixes 1,048,576
Each of the million or so prefixes is shared by many hashes, so the server can’t tell which one the client cares about. This is k-anonymity. Response size could still hint at the prefix to someone watching encrypted traffic, so an Add-Padding: true header pads every response to a random 800 to 1,000 records, the fake ones with a count of zero.
Even a long, unique, unbreached password has two weaknesses no policy fixes. It’s the same on every login, so anyone who sees it once can reuse it; NIST says passwords aren’t “replay-resistant.” And a person can be tricked into typing it on the wrong site. Second factors exist for these two weaknesses, and they vary a great deal in how well they handle them.
One-time codes from a shared secret
The six-digit code in an authenticator app comes from two short RFCs. Both rely on a secret shared between the app and the server, and on HMAC.
HOTP
RFC 4226 defines HOTP, the HMAC-based one-time password. The app and the server share a key and a counter . Each code is:
where is the number of digits, at least 6. The counter is an 8-byte big-endian integer. “Truncate” is dynamic truncation: the low four bits of the last HMAC byte pick an offset from 0 to 15, the four bytes there are read as an integer, and the top bit is masked off so signed and unsigned platforms agree.
The whole algorithm fits in a few lines:
import hashlib, hmac, struct
def hotp(key: bytes, counter: int, digits: int = 6, alg=hashlib.sha1) -> str:
mac = hmac.new(key, struct.pack(">Q", counter), alg).digest()
offset = mac[-1] & 0x0F # dynamic truncation
code = struct.unpack(">I", mac[offset:offset + 4])[0] & 0x7FFFFFFF
return str(code % 10 ** digits).zfill(digits)
def totp(key: bytes, unix_time: int, step: int = 30, digits: int = 6, alg=hashlib.sha1) -> str:
return hotp(key, unix_time // step, digits, alg)
The RFC’s Appendix D gives intermediate values for the key 12345678901234567890. Tracing counter 0 step by step:
HMAC-SHA-1 cc93cf18508d94934c64b65d8ba7667fb7cde4b0
last byte 0xb0 -> offset 0
4 bytes cc93cf18 -> masked 0x4c93cf18 = 1284755224
mod 10^6 755224
The HMAC matches the RFC’s Table 1, and 4c93cf18, 1284755224 and 755224 match its Table 2. A loop over counters 0 to 9 compares each code with the RFC’s list:
HOTP count=0 755224 expected 755224 ok
HOTP count=1 287082 expected 287082 ok
HOTP count=2 359152 expected 359152 ok
HOTP count=3 969429 expected 969429 ok
HOTP count=4 338314 expected 338314 ok
HOTP count=5 254676 expected 254676 ok
HOTP count=6 287922 expected 287922 ok
HOTP count=7 162583 expected 162583 ok
HOTP count=8 399871 expected 399871 ok
HOTP count=9 520489 expected 520489 ok
HOTP’s counter advances on the token whenever a code is generated, and on the server only when one is accepted, so button presses without a login push them out of step. RFC 4226 handles this with a look-ahead window, in which the server tries the next few counters and resynchronizes on a match. It also recommends throttling, since six digits is only a million possibilities, and a key of at least 128 bits, preferably 160.
TOTP
RFC 6238 removes the counter problem by deriving the counter from the clock:
with , the Unix epoch, and a time step of 30 seconds by default. TOTP is HOTP with as the counter, which is why totp above is one line. The RFC also allows HMAC-SHA-256 and HMAC-SHA-512.
Its Appendix B gives 8-digit codes at six timestamps for each hash. The text says the secret is 12345678901234567890, but the reference code in Appendix A, which produced the table, repeats it to 32 bytes for SHA-256 and 64 bytes for SHA-512. Only those longer keys reproduce the table:
keys = {"SHA1": (key, hashlib.sha1),
"SHA256": ((key * 2)[:32], hashlib.sha256), # the RFC's 32-byte seed
"SHA512": ((key * 4)[:64], hashlib.sha512)} # the RFC's 64-byte seed
# rfc6238 maps each timestamp in the table to its SHA1, SHA256 and SHA512 codes
for t, expected in rfc6238.items():
for (mode, (k, alg)), exp in zip(keys.items(), expected):
got = totp(k, t, digits=8, alg=alg)
print(f"TOTP t={t:<11} T={t // 30:016X} {mode:<6} {got} {'ok' if got == exp else 'FAIL'}")
print("SHA256 with 20-byte key at t=59:", totp(key, 59, digits=8, alg=hashlib.sha256))
TOTP t=59 T=0000000000000001 SHA1 94287082 ok
TOTP t=59 T=0000000000000001 SHA256 46119246 ok
TOTP t=59 T=0000000000000001 SHA512 90693936 ok
TOTP t=1111111109 T=00000000023523EC SHA1 07081804 ok
TOTP t=1111111109 T=00000000023523EC SHA256 68084774 ok
TOTP t=1111111109 T=00000000023523EC SHA512 25091201 ok
TOTP t=1111111111 T=00000000023523ED SHA1 14050471 ok
TOTP t=1111111111 T=00000000023523ED SHA256 67062674 ok
TOTP t=1111111111 T=00000000023523ED SHA512 99943326 ok
TOTP t=1234567890 T=000000000273EF07 SHA1 89005924 ok
TOTP t=1234567890 T=000000000273EF07 SHA256 91819424 ok
TOTP t=1234567890 T=000000000273EF07 SHA512 93441116 ok
TOTP t=2000000000 T=0000000003F940AA SHA1 69279037 ok
TOTP t=2000000000 T=0000000003F940AA SHA256 90698825 ok
TOTP t=2000000000 T=0000000003F940AA SHA512 38618901 ok
TOTP t=20000000000 T=0000000027BC86AA SHA1 65353130 ok
TOTP t=20000000000 T=0000000027BC86AA SHA256 77737706 ok
TOTP t=20000000000 T=0000000027BC86AA SHA512 47863826 ok
SHA256 with 20-byte key at t=59: 32247374
All eighteen match. The last line shows what the 20-byte key gives for SHA-256: a different code, because HMAC pads a short key with zeros to the hash’s block size, and a 20-byte key padded with zeros isn’t the same as a 32-byte key. Testing against the RFC’s prose instead of its code makes SHA-256 and SHA-512 fail. The timestamp 20000000000, in the year 2603, also checks that the counter is a full 64-bit value.
Enrolling a phone
The server generates a random key and has to get it into the user’s app. Nearly every authenticator app reads an otpauth URI, usually shown as a QR code. The format came from Google Authenticator, not a standards body:
import base64, secrets, urllib.parse
import qrcode, qrcode.image.svg
secret = secrets.token_bytes(20) # 160 bits, as RFC 4226 recommends
b32 = base64.b32encode(secret).decode().rstrip("=")
label = urllib.parse.quote("Example:ada@example.com")
uri = (f"otpauth://totp/{label}?secret={b32}&issuer=Example"
f"&algorithm=SHA1&digits=6&period=30")
print(uri)
qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M)
qr.add_data(uri); qr.make(fit=True)
qr.make_image(image_factory=qrcode.image.svg.SvgPathImage).save("enroll.svg")
print("QR version", qr.version, "->", qr.modules_count, "x", qr.modules_count, "modules")
otpauth://totp/Example%3Aada%40example.com?secret=W7ICMIR52G4F7UAATMNQDIRSGXG63L3J&issuer=Example&algorithm=SHA1&digits=6&period=30
QR version 8 -> 49 x 49 modules
The secret travels in unpadded base32, capital letters and the digits 2 to 7. The label names the account and issuer the service. Many apps ignore algorithm, digits and period and assume SHA-1, six digits and 30 seconds, so changing them breaks enrollment for some users.
That QR code is the secret. Anyone who photographs it can generate the same codes forever. The server should show it once, ask for a current code to prove the app received it, and only then activate the factor.
Verifying a code
A server checking a code has to decide how far apart the clocks may be, how to stop a code being used twice, and where the key lives.
RFC 6238 recommends allowing “at most one time step” for network delay, plus a policy for clock drift in both directions. Most servers accept the current step and one either side, so a code is valid for up to 90 seconds. On reuse the RFC is direct: “The verifier MUST NOT accept the second attempt of the OTP after the successful validation has been issued for the first OTP.” Storing the last accepted step per user, and refusing that step or earlier ones, does it.
The key is the awkward part. A password can be stored as a slow hash, but a TOTP key can’t, because the server must compute HMACs with it on every login. The usual answer is to encrypt it with a key held outside the database, such as one from a key management service, so a database dump on its own doesn’t hand over everyone’s second factor.
import hmac, os, secrets
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from otp import totp
STEP, WINDOW = 30, 1 # accept one step either side
# Secret storage: the server needs the raw key to compute codes, so it can't hash it.
# Encrypt it under a key kept outside the database, bound to the user id.
master = AESGCM(AESGCM.generate_key(bit_length=256)) # in practice: from a KMS
def seal(user_id: str, secret: bytes) -> bytes:
nonce = os.urandom(12)
return nonce + master.encrypt(nonce, secret, user_id.encode())
def unseal(user_id: str, blob: bytes) -> bytes:
return master.decrypt(blob[:12], blob[12:], user_id.encode())
class Account:
def __init__(self, user_id, secret):
self.user_id, self.sealed, self.last_step = user_id, seal(user_id, secret), -1
def verify(acct: Account, code: str, now: int) -> str:
key = unseal(acct.user_id, acct.sealed)
current = now // STEP
for step in range(current - WINDOW, current + WINDOW + 1):
if hmac.compare_digest(totp(key, step * STEP), code):
if step <= acct.last_step:
return "rejected: this step or a later one was already used"
acct.last_step = step
return f"accepted (matched step {step - current:+d})"
return "rejected: no match in window"
secret = secrets.token_bytes(20)
ada = Account("ada", secret)
print("sealed secret is", len(ada.sealed), "bytes")
t0 = 1_793_500_000 # a fixed clock for the demo
code_now = totp(secret, t0)
print("code at t0 ", verify(ada, code_now, t0))
print("same code again ", verify(ada, code_now, t0 + 5))
late = totp(secret, t0 + 30) # phone clock 30 s ahead
print("phone 30 s ahead ", verify(ada, late, t0))
print("code 2 steps old ", verify(ada, totp(secret, t0 - 60), t0 + 60))
try:
unseal("mallory", ada.sealed) # ciphertext copied to another row
except Exception as e:
print("unseal as mallory", type(e).__name__)
sealed secret is 48 bytes
code at t0 accepted (matched step +0)
same code again rejected: this step or a later one was already used
phone 30 s ahead accepted (matched step +1)
code 2 steps old rejected: no match in window
unseal as mallory InvalidTag
The 48 bytes are a 12-byte nonce, the 20-byte key and a 16-byte authentication tag. The user ID is associated data, so a ciphertext copied into another user’s row fails to decrypt. hmac.compare_digest takes the same time wherever the codes differ.
Accepting a code from step +1 moves last_step forward, so the current step’s code is refused afterwards. Once a later code has been seen, an earlier one has no legitimate use. The verifier also needs a rate limit. A six-digit code is a one-in-a-million guess, and NIST chose its ceiling of 100 failures with exactly that in mind.
Why codes can be phished
Everything above makes the code hard to guess and impossible to reuse. None of it helps if the user types a valid code into the wrong website. A phishing page doesn’t need to guess or replay anything. It only needs to pass the code along while it’s still valid, and 30 to 90 seconds is a long time for software.
The next program runs two local web servers. The real site on 127.0.0.1:5001 checks a scrypt-hashed password and a TOTP code with the replay rule above. A lookalike on 127.0.0.1:5002 shows the same form and forwards whatever it receives. A headless Chrome plays the victim, submitting the lookalike’s form with the code an authenticator app would show at that moment. The relay’s handler is the whole attack:
@relay.post("/login")
def relay_login():
f = request.form
log("relay", f"captured user={f['user']} pw={f['pw'][:3]}... code={f['code']}")
s = requests.Session()
r = s.post("http://127.0.0.1:5001/login", data=f)
log("relay", f"forwarded to bank -> {r.status_code} {r.text!r}, cookie sid={s.cookies.get('sid', '')[:8]}...")
r = s.get("http://127.0.0.1:5001/account")
log("relay", f"GET /account with the stolen cookie -> {r.status_code} {r.text!r}")
return "Something went wrong. Please try again later."
The output, with times measured from the start of the script:
1.45s victim reads 609486 from the authenticator app, types it into http://127.0.0.1:5002
1.46s relay captured user=ada pw=vio... code=609486
1.46s bank login attempt for ada from 127.0.0.1, User-Agent python-requests/
1.49s bank password and code correct, session issued
1.49s relay forwarded to bank -> 200 'signed in', cookie sid=mTGrTDV5...
1.50s relay GET /account with the stolen cookie -> 200 'balance page for ada'
2.96s victim sees: 'Something went wrong. Please try again later.'
The relay had a working session 40 milliseconds after the victim pressed the button. The code was correct, unused and inside its window, so every check on the real site passed. The replay rule worked too, against the victim: if she now goes to the real site and types the same code, it will be refused as already used.
The only trace on the real site is the python-requests user agent. Phishing toolkits used in practice run as a reverse proxy that passes the victim’s headers through, so even that disappears, and they keep the session cookie, so the attacker never has to log in again. The JWT essay’s section on session attacks covers what can be done about a stolen session after the fact.
NIST states the general rule plainly: authenticators “that involve the manual entry of an authenticator output (e.g., out-of-band and OTP authenticators) SHALL NOT be considered phishing-resistant because the manual entry does not bind the authenticator output to the specific session being authenticated.” The code says nothing about which site it was meant for, and only the user could notice. TOTP still stops guessing and the reuse of passwords leaked from other sites. It doesn’t stop a live attacker in the middle.
SMS codes and the phone number
A code sent by text message has the same relay weakness as TOTP, and adds another: the secret doesn’t go to a device, it goes to a phone number, and the number is controlled by a mobile carrier.
In a SIM swap, an attacker persuades the carrier’s support staff to move the number to a SIM card the attacker holds. In a port-out, the number moves to a different carrier. Either way the next text message goes to the attacker, and the victim’s phone just loses service. Malware that reads incoming messages does the same without the carrier.
NIST handles this with a category it calls restricted authenticators. Delivering a secret over the public telephone network, by SMS or by voice call, is the only one. A service may use it, but must offer an unrestricted alternative, tell users the risk, and plan for the day it’s no longer allowed. NIST adds that verifiers “SHOULD consider risk indicators (e.g., device swap, SIM change, number porting, other abnormal behavior) before using the PSTN,” and that changing the registered phone number counts as binding a new authenticator, with all the checks that implies. The OWASP Multifactor Authentication Cheat Sheet goes further: “Do not use SMS for high-value or PII-handling applications.”
NIST also rules out email for out-of-band authentication. A confirmation code sent to an email address is still allowed for proving the user owns the address and for account recovery, but it doesn’t count as an authentication factor.
SMS remains common because every phone can receive it with no setup, and it still beats no second factor against an attacker who only has a leaked password.
Push approvals and fatigue
A push prompt replaces typing with a button. The site notifies an app on the user’s phone, and the user taps “Approve”. It runs over an authenticated channel to the app, not the phone network, so SIM swaps don’t affect it.
Its weakness is that approving takes no knowledge at all. An attacker with the password can trigger prompts repeatedly, often at night, until the user approves one to make them stop. This is called MFA fatigue or push bombing.
Number matching is the usual fix. The login page shows a number, and the user types it into the phone app to approve. An approval now requires looking at the login screen that started it, and a user being bombed has no number to enter.
NIST Revision 4 requires it. Asking the user only to confirm that two values match “is no longer considered acceptable because it increases the likelihood that the subscriber would approve an authentication request without actually comparing the secrets.” The secret must be typed across, from phone to login page or the other way. Picking from three numbers doesn’t count, since users can guess, and push notifications should be rate-limited.
Number matching defeats the bombing attack. It doesn’t defeat a relay, because the phishing page can display the number it got from the real site, and the user will dutifully type it in.
Backup and recovery codes
Every second factor can be lost. The usual fallback is a set of single-use recovery codes, shown once when the user enables a second factor and meant to be printed or stored offline.
They’re a credential that bypasses the second factor, so NIST’s rules for look-up secrets and saved recovery codes treat them like passwords. They must come from a secure random generator, and a saved recovery code must carry at least 64 bits. Each works only once. They’re stored hashed, and codes under 112 bits of entropy need a salt of at least 32 bits and a password hashing function. Verification is rate-limited.
The following generates ten 80-bit codes, stores them in PostgreSQL 16 with a salted scrypt hash each, and redeems them:
import base64, hashlib, hmac, os, secrets, threading, time
import pgserver, psycopg
def new_code() -> str:
raw = base64.b32encode(secrets.token_bytes(10)).decode() # 80 random bits
return "-".join(raw[i:i + 4] for i in range(0, 16, 4)).lower()
def normalize(code: str) -> bytes:
return code.replace("-", "").replace(" ", "").upper().encode()
def kdf(code: str, salt: bytes) -> bytes:
return hashlib.scrypt(normalize(code), salt=salt, n=2**14, r=8, p=1, dklen=32)
srv = pgserver.get_server(os.path.abspath("pgdata"), cleanup_mode="stop")
db = psycopg.connect(srv.get_uri(), autocommit=True)
db.execute("""
DROP TABLE IF EXISTS recovery_codes;
CREATE TABLE recovery_codes (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id text NOT NULL,
salt bytea NOT NULL,
code_hash bytea NOT NULL,
used_at timestamptz
);""")
codes = [new_code() for _ in range(10)]
for c in codes:
salt = os.urandom(16)
db.execute("INSERT INTO recovery_codes (user_id, salt, code_hash) VALUES (%s, %s, %s)",
("ada", salt, kdf(c, salt)))
print("shown to the user once:", ", ".join(codes[:3]), "...")
print(db.execute("SELECT id, user_id, encode(code_hash, 'hex') AS hash, used_at "
"FROM recovery_codes LIMIT 2").fetchall())
def redeem(user: str, code: str) -> bool:
with psycopg.connect(srv.get_uri(), autocommit=True) as conn:
rows = conn.execute("SELECT id, salt, code_hash FROM recovery_codes "
"WHERE user_id = %s AND used_at IS NULL", (user,)).fetchall()
for row_id, salt, stored in rows:
if hmac.compare_digest(kdf(code, salt), bytes(stored)):
# The WHERE clause makes the use atomic: only one caller can flip it.
won = conn.execute("UPDATE recovery_codes SET used_at = now() "
"WHERE id = %s AND used_at IS NULL RETURNING id",
(row_id,)).fetchone()
return won is not None
return False
# The rest of the script redeems codes[4] twice (timing the first), codes[5] retyped
# in uppercase with spaces, a fresh random code, and codes[7] from two threads at once.
shown to the user once: gue5-xgwj-lofy-4vay, 3wgi-lkfm-c663-ul4s, k2ow-wmos-bxlo-t6t7 ...
[(1, 'ada', '54a88e40fb405d52f16a8583519e0ae5b1eed01620ebee1afaef92805498c14b', None), (2, 'ada', '461d3b69fe143035b1ef161e48fb0e72dcf70f868d222fe1940fecf6a08585d7', None)]
redeem codes[4] True (138 ms)
redeem codes[4] again False
redeem typed in uppercase True
redeem a wrong code False
two concurrent uses of codes[7]: [False, True]
unused codes left: 7
The pgserver package runs a private PostgreSQL server from a Python virtual environment, so this needed no system-wide install.
Normalization. Users retype codes from paper, with or without dashes, in either case. The code is normalized before hashing, so GUE5 XGWJ ... and gue5-xgwj-... hash the same. Base32 uses only the letters and the digits 2 to 7, so there’s no 0 to confuse with O or 1 to confuse with I.
Atomic use. The UPDATE ... WHERE used_at IS NULL RETURNING id is what makes a code single-use. Checking and then marking in two separate steps would let two simultaneous requests both succeed. With the condition in the UPDATE, only one changes the row, and the two-thread test shows exactly one True.
Cost. Each salted scrypt hash at these settings takes about 27 ms on this machine, and redemption hashes the input once per unused code until one matches. codes[4] was the fifth row, so its redemption took 138 ms, and a wrong code costs all ten hashes. That’s fine for a rare path, but a reason to rate-limit it hard. Making part of each code a public identifier that selects the row would cut it to one hash.
When a code is used, the user should be notified and nudged to replace the lost factor. For its single saved recovery code, NIST also requires issuing a new one after use.
WebAuthn and passkeys
Every factor so far has the same flaw: the user carries a secret or an approval from one place to another, and a phishing page can sit in the middle of that trip. WebAuthn removes the trip. The browser tells the authenticator which site is asking, and the authenticator signs a response that names the site.
Web Authentication Level 3, a W3C Recommendation since August 2026, is the browser half of what the FIDO Alliance calls FIDO2. The other half, CTAP, is how a browser talks to an external authenticator such as a security key or a phone. The spec lists passkey as a synonym for a discoverable credential, one the authenticator can find without the site first naming it. The FIDO Alliance defines it as “an authentication credential based on FIDO standards.”
The two ceremonies
Registration creates a credential. The server, called the Relying Party (RP), sends options: its RP ID, the user’s account ID and name, the algorithms it accepts, whether it needs user verification, and a random challenge. The page passes these to navigator.credentials.create(). The browser checks that the RP ID is the page’s own domain or a parent of it, then asks the authenticator to create a new key pair scoped to that RP ID. The authenticator keeps the private key and returns the public key, a credential ID, and an attestation object. The server verifies the response and stores the public key with the account.
Authentication uses it. The server sends a fresh challenge and, optionally, the IDs of credentials the user has. The page calls navigator.credentials.get(). The authenticator finds a credential for that RP ID, checks that the user is present and, if asked, verifies them with a PIN or biometric, and signs. The server checks the signature with the stored public key.
The server never holds anything secret. A dump of its WebAuthn table is a list of public keys, useless to an attacker, which is already a large improvement over passwords and TOTP keys.
What gets signed
The authenticator signs two things concatenated: the authenticator data and the SHA-256 hash of the client data JSON.
The client data is built by the browser, not the page. It contains the operation type (webauthn.create or webauthn.get), the challenge, and the origin of the page that made the call. A phishing page can’t make the browser report a different origin.
The authenticator data is built by the authenticator. In the spec it’s at least 37 bytes: a 32-byte SHA-256 hash of the RP ID, one byte of flags, and a 4-byte big-endian signature counter. During registration it also carries the new credential’s ID and public key. The flags that matter most are UP (bit 0, a user was present, typically a touch), UV (bit 2, the user was verified with a PIN or biometric), BE and BS (bits 3 and 4, the credential can be backed up and currently is), and AT (bit 6, attested credential data is included).
The cryptographic core is small enough to write out with a P-256 key. This isn’t a WebAuthn implementation, since registration is reduced to “the server has the public key”, but the signing and checking follow the spec’s steps:
import base64, hashlib, json, secrets, struct
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
b64u = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=").decode()
UP, UV, BE, BS = 0x01, 0x04, 0x08, 0x10
# ---- registration, reduced to its result: the server keeps a public key ----
private_key = ec.generate_private_key(ec.SECP256R1()) # lives in the authenticator
public_key = private_key.public_key() # stored by the server
stored = {"rp_id": "bank.example", "origin": "https://bank.example", "sign_count": 0}
# ---- authentication: the authenticator and browser side ----
def assert_login(challenge: bytes, origin: str, rp_id: str, counter: int):
client_data = json.dumps({"type": "webauthn.get", "challenge": b64u(challenge),
"origin": origin, "crossOrigin": False},
separators=(",", ":")).encode() # built by the browser
auth_data = (hashlib.sha256(rp_id.encode()).digest() # rpIdHash
+ bytes([UP | UV]) # flags
+ struct.pack(">I", counter)) # signCount
sig = private_key.sign(auth_data + hashlib.sha256(client_data).digest(),
ec.ECDSA(hashes.SHA256()))
return client_data, auth_data, sig
# ---- the server side ----
def verify(challenge: bytes, client_data: bytes, auth_data: bytes, sig: bytes) -> str:
try:
public_key.verify(sig, auth_data + hashlib.sha256(client_data).digest(),
ec.ECDSA(hashes.SHA256()))
except InvalidSignature:
return "FAIL: signature does not verify"
c = json.loads(client_data)
if c["type"] != "webauthn.get": return "FAIL: type"
if c["challenge"] != b64u(challenge): return "FAIL: challenge"
if c["origin"] != stored["origin"]: return f"FAIL: origin {c['origin']}"
if auth_data[:32] != hashlib.sha256(stored["rp_id"].encode()).digest():
return "FAIL: rpIdHash"
flags, count = auth_data[32], struct.unpack(">I", auth_data[33:37])[0]
if not flags & UP: return "FAIL: user not present"
if not flags & UV: return "FAIL: user not verified"
if (count or stored["sign_count"]) and count <= stored["sign_count"]:
return f"FAIL: counter {count} not above {stored['sign_count']}, possible clone"
stored["sign_count"] = count
return f"ok (flags=0x{flags:02x}, signCount={count})"
ch = secrets.token_bytes(32)
cd, ad, sig = assert_login(ch, "https://bank.example", "bank.example", 1)
print("clientDataJSON ", cd.decode())
print("authenticatorData", ad.hex())
print("signature (DER) ", len(sig), "bytes")
print("genuine login ", verify(ch, cd, ad, sig))
forged = cd.replace(b"https://bank.example", b"https://bank-login.example")
print("origin edited after ", verify(ch, forged, ad, sig))
ch2 = secrets.token_bytes(32)
print("phishing page signs ", verify(ch2, *assert_login(ch2, "https://bank-login.example",
"bank-login.example", 2)))
print("replayed old response ", verify(secrets.token_bytes(32), cd, ad, sig))
ch3 = secrets.token_bytes(32)
print("counter went backward ", verify(ch3, *assert_login(ch3, "https://bank.example", "bank.example", 1)))
clientDataJSON {"type":"webauthn.get","challenge":"nzVu6lRCN5auINlEob_NOz39VzqO9BxeEgEkjwxKQt8","origin":"https://bank.example","crossOrigin":false}
authenticatorData 05be55af508c5555d806d5bd5490f5e21dab9a101b88367f8d1d063f8c3bfc3f0500000001
signature (DER) 71 bytes
genuine login ok (flags=0x05, signCount=1)
origin edited after FAIL: signature does not verify
phishing page signs FAIL: origin https://bank-login.example
replayed old response FAIL: challenge
counter went backward FAIL: counter 1 not above 1, possible clone
The authenticator data is exactly 37 bytes: the RP ID hash, then 05 for UP and UV, then 00000001 for the counter. Each failure is a different defense.
Editing the origin after signing breaks the signature, because the hash of the client data is part of what was signed, so a relay can’t relabel a response made for its own origin. A response made on the phishing origin has a valid signature but names the wrong origin, and the server refuses it. In this demo one key signs for both sites to show that server-side check; in reality the browser wouldn’t let bank-login.example use a credential scoped to bank.example at all, as the real browser run below shows. A replayed response carries an old challenge, and the server issues a fresh one, at least 16 random bytes by the spec, for each ceremony. A counter that doesn’t increase may mean the key has been copied, which the spec calls “a signal, but not proof.”
A real ceremony in Chrome
Chrome’s DevTools Protocol has a WebAuthn domain that adds a virtual authenticator to a tab. It goes through the browser’s real WebAuthn code, including the origin and RP ID checks. The next program starts headless Chrome, adds one, and runs registration and login against a Flask server on http://localhost:5003, which verifies responses with the webauthn Python library, version 3.0.1. Browsers treat http://localhost as a secure context, so no certificate is needed. The page code uses parseCreationOptionsFromJSON and toJSON to convert between JSON and the API’s binary structures:
async function register() {
const opts = await (await fetch('/register/options')).json();
const cred = await navigator.credentials.create(
{publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(opts)});
return (await fetch('/register/verify', {method: 'POST', body: JSON.stringify(cred.toJSON())})).text();
}
async function login() {
const opts = await (await fetch('/login/options')).json();
const cred = await navigator.credentials.get(
{publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(opts)});
window.last = cred.toJSON();
return (await fetch('/login/verify', {method: 'POST', body: JSON.stringify(window.last)})).text();
}
The server’s registration options require a discoverable credential and user verification. The test driver attaches the authenticator through the protocol:
b.send("WebAuthn.enable")
auth = b.send("WebAuthn.addVirtualAuthenticator", options={
"protocol": "ctap2", "transport": "internal", "hasResidentKey": True,
"hasUserVerification": True, "isUserVerified": True,
"automaticPresenceSimulation": True,
"defaultBackupEligibility": SYNCED, "defaultBackupState": SYNCED})["authenticatorId"]
After the genuine logins, the driver loads a second site on http://localhost:5004, which serves the same page but relays the real server’s challenge and forwards the browser’s response back, as a phishing proxy would. Finally a page on http://127.0.0.1:5003 asks for a credential with RP ID localhost:
virtual authenticator 82141955-a0ab-42a5-85c7-e513eaf86412
register on http://localhost:5003
[server] registration clientDataJSON: {"type": "webauthn.create", "challenge": "E0Q5OslobQCpcHuuyxV3oMMIhJee5uEISk_a2mfAw333mMJIy_9-z9T_y6w1NqGJ3wu_LBG-dI_AsfaAoU5XKA", "origin": "http://localhost:5003", "crossOrigin": false}
[server] registration authenticatorData flags=0x45 signCount=1 (129 bytes with attested credential data)
[page] registered: fmt=none aaguid=01020304-0506-0708-0102-030405060708 user_verified=True sign_count=1 device_type=single_device backed_up=False id=5049f64d5544d65d...
login on http://localhost:5003
[server] clientDataJSON origin=http://localhost:5003 type=webauthn.get
[server] authenticatorData: rpIdHash=49960de5880e8c68... flags=0x05 signCount=2
[page] signed in: user_verified=True new_sign_count=2
login again
[server] clientDataJSON origin=http://localhost:5003 type=webauthn.get
[server] authenticatorData: rpIdHash=49960de5880e8c68... flags=0x05 signCount=3
[page] signed in: user_verified=True new_sign_count=3
authenticator holds 1 credential: rpId=localhost isResidentCredential=True signCount=3
login on the relay at http://localhost:5004
[server] clientDataJSON origin=http://localhost:5004 type=webauthn.get
[server] authenticatorData: rpIdHash=49960de5880e8c68... flags=0x05 signCount=4
[page] rejected: InvalidAuthenticationResponse: Unexpected client data origin "http://localhost:5004", expected "http://localhost:5003"
page on http://127.0.0.1:5003 asks for rpId 'localhost'
[page] SecurityError: This is an invalid domain.
The registration flags are 0x45: UP, UV and AT, the last because registration carries the new public key, which is why that authenticator data is 129 bytes instead of 37. The login flags are 0x05, UP and UV. The rpIdHash prefix 49960de5880e8c68 is the SHA-256 of the string localhost. The counter rises by one on each use, and the server stores the new value.
The relay on port 5004 shows the first layer of origin binding. An RP ID is a domain name without a scheme or port, so localhost:5004 is allowed to use a credential for RP ID localhost, and the browser signed. But it recorded the true origin, http://localhost:5004, in the client data, and the server refused it. The relay that beat TOTP in 40 milliseconds gets nothing here, and can’t fix the origin because the signature covers it.
The last line shows the second, stronger layer. A page on 127.0.0.1 asked for a credential belonging to localhost. The browser refused before consulting the authenticator, because the RP ID must be the page’s own domain or a registrable parent of it. This is the layer a real phishing site hits: bank-login.example can’t claim RP ID bank.example, so the bank’s passkey is never offered, and the user doesn’t have to notice anything. NIST calls this kind of protection verifier name binding and lists WebAuthn as its example.
The same run with SYNCED=1, which sets the backup flags on the virtual authenticator, differs only in these lines:
[server] registration authenticatorData flags=0x5d signCount=1 (129 bytes with attested credential data)
[page] registered: fmt=none aaguid=01020304-0506-0708-0102-030405060708 user_verified=True sign_count=1 device_type=multi_device backed_up=True id=742ec0aecafc111f...
[server] authenticatorData: rpIdHash=49960de5880e8c68... flags=0x1d signCount=2
[server] authenticatorData: rpIdHash=49960de5880e8c68... flags=0x1d signCount=3
[server] authenticatorData: rpIdHash=49960de5880e8c68... flags=0x1d signCount=4
0x1d adds BE and BS to UP and UV, and the library reports the credential as multi_device and backed_up. That’s how a server can tell a synced passkey from one that lives on a single device.
Flags, counters and attestation in practice
UP is not UV. User presence means someone touched the authenticator. User verification means it checked a PIN or biometric, which makes the passkey two factors by itself: something you have, unlocked by something you know or are. As the OWASP cheat sheet puts it, presence alone isn’t a second factor, so a server replacing password plus second factor with a passkey must request UV and check the flag.
The counter is weak evidence. Authenticators without a counter leave it at zero, and the spec skips the check when both values are zero. A counter is hard to keep consistent across devices sharing one key, so synced passkeys often report zero, though Chrome’s virtual authenticator counted in both modes. A counter going backwards on a hardware key deserves an alert.
Attestation is mostly off. At registration, an authenticator can include an attestation statement, signed by a key its manufacturer certified, proving its make and model. The spec defines formats such as packed, tpm, android-key, apple and none. The run above used none, and the virtual authenticator’s AAGUID, the model identifier, is a placeholder. Consumer sites rarely need attestation. It matters to organizations that issue specific security keys and want to accept only those.
Synced passkeys and device-bound keys
The FIDO Alliance uses two terms. Synced passkeys are copied between a user’s devices through a cloud service, such as the platform’s password manager. Device-bound passkeys “never leave a single device,” which is the case for passkeys on a hardware security key.
Synced passkeys solve the biggest problem with security keys, losing the device: a new phone signed in to the same platform account has them immediately. The cost is that the private key exists in several places, and its safety depends on the sync service and the account behind it. Its appendix on syncable authenticators accepts them at AAL2 and requires that access to the keys in the sync service be protected by “AAL2-equivalent MFA.” But “syncable authenticators SHALL NOT be used at AAL3,” because AAL3 requires a non-exportable key in protected hardware.
Both kinds are equally phishing-resistant, since the origin binding comes from the browser and the RP ID, not the hardware. What differs is the risk of the key being copied, and how much recovery depends on a third-party account. Two features make either kind easier to use: the hybrid transport lets a phone act as the authenticator for a nearby computer, usually started by scanning a QR code, and conditional mediation lets passkeys appear in the browser’s autofill list on an ordinary username field.
Account recovery is the weakest link
An account is only as strong as the easiest way into it. If a lost passkey can be replaced by clicking a link in an email, then the account’s real security is the email account’s security, and a phishing-resistant login has been bolted to a recovery path that isn’t.
Weak recovery paths depend on something easier to get than the factor itself: a phone number, an email inbox protected only by a password, a support agent who can be persuaded, or security questions answered on social media. NIST describes the acceptable options as repeating part of identity proofing, saved recovery codes, issued recovery codes sent to a pre-registered address, and trusted recovery contacts. It also requires that “An account recovery event always causes one or more notifications to be sent to the subscriber.”
The best recovery is the one that’s never needed. NIST says services “SHOULD encourage subscribers to maintain at least two separate means of authentication,” such as two passkeys, or a passkey and recovery codes. Adding, removing or changing a factor should require a fresh login with an existing factor; OWASP warns against relying on the active session alone, “as it may be hijacked.” The user should be notified through a channel other than the one being changed, and recovery deserves the same rate limits as login. The Web Security track looks at these flows from the browser and request side.
Recovery is also where support staff become part of the authentication system. A help desk that disables MFA after a convincing story is an authenticator with no rate limit and no cryptography.
Step-up and re-authentication
A login proves who someone was when they logged in. A session that started yesterday on a password and a text message says little about who is clicking “transfer funds” now. So services ask again, or for a stronger factor, at sensitive moments: changing the email address or password, adding or removing a factor, viewing recovery codes, moving money, or entering an admin area. The OWASP cheat sheet lists much the same actions. NIST sets limits on reauthentication at AAL2: an overall timeout that “SHOULD be no more than 24 hours” and an inactivity timeout of no more than an hour.
Passkeys make this cheap. A fresh navigator.credentials.get() with userVerification: "required" costs the user one fingerprint or PIN, and it binds the confirmation to the site and to a fresh challenge. The server should check the UV flag and the time of the ceremony, not just that a session exists. The mechanics of asking for stronger authentication across services, including the acr and auth_time claims and RFC 9470’s insufficient_user_authentication error, are covered in the JWT essay’s section on step-up authentication.
Single sign-on: SAML, OpenID Connect and one place for MFA
Most organizations don’t want every application to implement everything above. Single sign-on moves the login to one identity provider (IdP), and the applications trust its statement about who the user is.
Two protocols carry that statement. SAML 2.0, an OASIS standard from 2005, sends a signed XML assertion through the browser, usually as a form POST, and is still common in enterprise software. OpenID Connect builds on OAuth 2.0 and returns a signed JWT, the ID token, from the authorization code flow. It’s simpler to implement, suits mobile and single-page applications, and is what most new integrations use. Both carry the same essentials: who the user is, which application the statement is for, when it expires, and how the user logged in. OIDC expresses the last of these in the acr and amr claims. The JWT essay’s OpenID Connect section covers ID tokens and the separate sessions that SSO creates.
Centralizing login helps MFA in concrete ways. Passkeys are scoped to a domain, so with SSO the user registers one passkey for the IdP and every application behind it benefits, instead of each application writing its own WebAuthn code. Requiring phishing-resistant factors or step-up becomes a setting at the IdP rather than a change in every codebase, and applications can read acr or amr to see what was used. The weakest link, recovery, exists once, where it can be reviewed. And push bombing, relays from odd locations and repeated recovery requests are far easier to spot when every login passes through one system.
The cost is concentration. A compromised IdP, or a weak recovery path at it, opens every application at once, so the IdP itself needs the strongest factors and the most careful recovery.
Comparing the factors
“Phishing-resistant” here uses NIST’s definition: the protocol keeps an impostor from getting a usable output without relying on the user to notice. Cost is relative, covering what the service builds and what the user must have.
| Factor | Phishing-resistant | Server stores | Main attacks | Cost | Recovery when lost |
|---|---|---|---|---|---|
| Password alone | No | Slow salted hash | Phishing, reuse from other breaches, guessing | Lowest | Reset by email or other factor |
| SMS or voice code | No; NIST “restricted” | Phone number | Relay, SIM swap, number porting, malware reading SMS | Low for users, per-message cost for services | Easy, which is part of the problem: a new SIM is the recovery |
| TOTP app | No | Shared key, encrypted | Relay within the window, stolen QR or backup | Low; free apps | Recovery codes or re-enrollment |
| Push with number matching | No | Device registration | Relay; fatigue without number matching | Medium; needs a vendor app | Re-enroll the app on a new phone |
| Recovery codes | No | Salted slow hashes | Theft of the printout, phishing | Very low | They are the recovery |
| Synced passkey | Yes | Public key | Compromise of the sync account, weak recovery | Low; built into platforms | Comes back with the platform account |
| Device-bound key (security key) | Yes | Public key | Physical theft together with the PIN | A device per user, ideally two | A second registered key; otherwise full recovery |
Three patterns stand out. Only the two rows that use WebAuthn resist phishing, and they do it without asking the user to check anything. The factors that are easiest to recover are the easiest to take over, because recovery and attack follow the same path. And every row above the last two leaves something phishable in the user’s hands.
Closing
A login has to answer two questions: does this person have the right secret, and are they giving it to the right site? Passwords, codes and push prompts answer the first and leave the second to the user. The relay turned a correct, unused, in-window code into a stolen session in 40 milliseconds.
WebAuthn answers the second question in the browser. The client data names the origin, the signature covers it, and the RP ID scoping means a lookalike domain isn’t offered the credential at all. The real ceremony in Chrome showed both halves: a relay on another origin got a signature the server refused, and a page on a different domain got nothing.
What remains are the parts around the login: recovering an account when every factor is gone, deciding when to ask again, and keeping all of it in one well-guarded place. Those are where accounts with good authentication still get taken over.