Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.
Introduction
FIDO2 is the umbrella standard (CTAP2 between client and authenticator, plus the W3C WebAuthn API between browser and relying party) that makes public-key cryptography usable as a consumer-grade second factor — or, with discoverable credentials, as the primary factor (“passkeys”). It is called *phishing-resistant* because the cryptographic assertion is bound to the exact origin that requested it: a credential registered for https://corp.example simply will not produce a valid signature for https://corp-example.com, no matter how convincing the phishing page looks to the human.
This is a qualitatively different property than OTP codes or push approvals, both of which a user can be tricked into relaying to an attacker’s proxy (adversary-in-the-middle phishing kits like Evilginx exist specifically to do this). WebAuthn removes the human from that loop: the browser, not the user, decides which origin the signature is scoped to, so there is nothing for a phishing page to trick the user into approving on the attacker’s behalf.
Attack Prerequisites
WebAuthn resists classic phishing by design, but real deployments are still attacked at their edges. Conditions that matter:
- A downgrade path still enabled — SMS OTP, TOTP, or push approval left available as a fallback factor, which an attacker phishes instead of attacking WebAuthn directly.
- Session hijacking after authentication — WebAuthn secures the login ceremony, not the session it produces; a stolen session cookie or bearer token bypasses it entirely regardless of how strong the original authentication was.
- A relying party that skips origin or User Verification checks — accepting an assertion without confirming
clientDataJSON.originmatches exactly, or not requiring the User Verification (UV) flag on a sensitive action. - Physical or malware access to an unlocked authenticator — a platform authenticator (Windows Hello, Touch ID) or an unattended, unlocked security key can be used by whoever is present at the device.
How It Works
Three parties participate: the Relying Party (RP) — the application server, identified by an rpId such as corp.example — the client (the browser, via navigator.credentials), and the authenticator (a security key, TPM, or platform module holding the private key). Registration (navigator.credentials.create()) has the RP send a PublicKeyCredentialCreationOptions object containing a random challenge, the rp and user identifiers, and accepted algorithms (-7 for ES256, -257 for RS256). The authenticator generates a fresh key pair scoped to that rpId, and returns an attestation object containing authData (RP ID hash, flags, sign counter, and the new public key) plus clientDataJSON (the challenge, origin, and ceremony type).
Authentication (navigator.credentials.get()) works the same way in reverse: the RP sends a fresh challenge, the authenticator signs authenticatorData || SHA-256(clientDataJSON) with the private key for that rpId, and the browser — not JavaScript on the page — populates clientDataJSON.origin from the actual address bar. This is the phishing-resistance mechanism: the origin binding happens inside the browser’s trusted code, so a malicious page cannot lie about which origin is asking, and the authenticator cannot be tricked into signing for an rpId it wasn’t registered against.
Attestation (optional, controlled by the RP’s attestation preference) lets the RP learn what kind of authenticator was used and, for direct/enterprise attestation, verify it against a manufacturer certificate chain — common formats are packed, tpm, android-key, fido-u2f, and none. Most consumer deployments use none and rely instead on the origin binding and signature verification for security, reserving attestation checks for high-assurance environments that need to restrict which specific authenticator models are trusted.
Practical Example / Configuration
Server-side verification of an authentication ceremony has to check every one of several fields — skipping any one reintroduces the problem WebAuthn was meant to solve. A correct verification, using the stored credential’s public key and previous sign counter:
def verify_assertion(stored_cred, response, expected_challenge, expected_origin, expected_rp_id):
client_data = json.loads(response.client_data_json)
assert client_data["type"] == "webauthn.get"
assert client_data["challenge"] == expected_challenge
assert client_data["origin"] == expected_origin # exact match, not substring
auth_data = parse_auth_data(response.authenticator_data)
rp_id_hash = sha256(expected_rp_id.encode())
assert auth_data.rp_id_hash == rp_id_hash
assert auth_data.flags.user_present
assert auth_data.flags.user_verified # require UV for step-up actions
signed = response.authenticator_data + sha256(response.client_data_json)
verify_signature(stored_cred.public_key, signed, response.signature)
assert auth_data.sign_count == 0 or auth_data.sign_count > stored_cred.sign_count
stored_cred.sign_count = auth_data.sign_count # clone detection
PythonA permissive server that skips the origin comparison and the sign counter check accepts a signature that is still cryptographically valid but no longer proves *which site* the user interacted with, and loses the ability to detect a cloned authenticator (two devices producing the same private key material, e.g. from a firmware flaw, diverge in sign-counter progression):
# VULNERABLE: origin and sign counter are never checked.
def verify_assertion_weak(stored_cred, response, expected_challenge):
client_data = json.loads(response.client_data_json)
assert client_data["challenge"] == expected_challenge
signed = response.authenticator_data + sha256(response.client_data_json)
verify_signature(stored_cred.public_key, signed, response.signature)
return True
PythonWalkthrough / Exploitation
Reviewing a WebAuthn integration as an assessor means walking both ceremonies and confirming each server-side check is present, not just that the UI shows a green checkmark:
1. Capture a registration ceremony (browser devtools -> Network) and
confirm the server persists: credentialId, public key (COSE), sign
counter, and the rpId/origin it was registered against.
2. Capture an authentication ceremony and diff clientDataJSON.origin
against the RP's canonical origin -- test with a homograph or
subdomain origin to confirm the server rejects a mismatch.
3. Replay a previously used assertion (same signature, same
challenge) -- it must be rejected because the challenge is single-use.
4. For step-up/sensitive actions, confirm the UV flag is actually
required server-side, not just requested client-side.
TEXTOn the client side, verify the RP is using discoverable credentials (residentKey: "required") correctly if offering passwordless login, and that authenticatorSelection.userVerification is set to "required" wherever the RP’s threat model calls for AAL3-equivalent assurance per NIST SP 800-63B.
Note: The sign counter is a heuristic, not a guarantee — many platform authenticators (e.g. those backed by cloud-synced passkeys) intentionally return a static or non-monotonic counter of 0, so relying parties should not hard-fail on a non-increasing counter for credential types known to behave this way; treat it as a signal for roaming/hardware keys specifically.
Opsec: When pentesting WebAuthn flows, do not attempt to extract private key material from a hardware authenticator — it is designed to be non-exportable and doing so is out of scope for an application assessment. Focus testing on the RP’s verification logic and session handling after authentication, which is where real-world gaps actually occur.
Detection and Defense
- Require User Verification for authentication and for any step-up action, and prefer
attestation: "none"unless there is a specific need to restrict authenticator models. - Validate origin, challenge, type, and rpIdHash on every ceremony, server-side, using a maintained WebAuthn library rather than hand-rolled parsing.
- Disable weaker fallback factors (SMS OTP) for accounts enrolled in WebAuthn where the threat model requires phishing resistance, or at minimum alert when a fallback factor is used.
- Monitor sign-counter anomalies and credential registrations from unexpected authenticator AAGUIDs as indicators of cloning or fraud.
- Protect the post-auth session with the same rigor (short-lived tokens, IP/device binding, reauthentication for sensitive actions) since WebAuthn does not extend its guarantees past the login ceremony.
Real-World Impact
Google’s internal deployment of hardware security keys for all employees, reported in 2018, resulted in zero successful account takeovers via phishing among the enrolled population — a widely cited case for the practical effectiveness of origin-bound authentication over OTP and push-based MFA, both of which remain phishable via adversary-in-the-middle proxy kits. FIDO2/WebAuthn adoption has since expanded from enterprise security keys to consumer-facing passkeys across major platform vendors.
Conclusion
WebAuthn’s phishing resistance comes from a specific architectural choice — the browser, not the user, binds every assertion to an origin — and that guarantee is only as strong as the relying party’s server-side verification of origin, challenge, and user verification on every ceremony. Get those checks right, keep fallback factors restricted, and protect the session that follows, and phishing credential theft stops being a viable path into the account.
You Might Also Like
If you found this useful, these related deep-dives cover adjacent techniques and their defenses:



Comments