How WebAuthn Prevents Phishing Attacks
WebAuthn eliminates credential theft by cryptographically binding each key pair to the exact relying party (RP) ID registered during enrollment. Unlike shared secrets or one-time passwords, private keys never leave the hardware or platform authenticator, and the browser enforces origin binding before any signature is produced — making man-in-the-middle and phishing-site credential reuse mathematically impossible. This page covers the exact error signatures you will see when phishing resistance activates, the root cause for each, numbered debugging steps, and CI/CD test patterns to validate the behaviour continuously. For the broader protocol stack that defines these trust boundaries, see Understanding WebAuthn vs FIDO2 Architecture.
Error Signatures and Spec Constraints
The table below maps every browser-enforced phishing-resistance failure to its DOMException, the W3C WebAuthn spec condition that triggers it, and the HTTP status your server should return when it detects the same mismatch.
| DOMException | Spec condition (W3C WebAuthn Level 3) | Trigger | Server HTTP status |
|---|---|---|---|
NotAllowedError |
§7.2 step 6 — origin does not match rpId effective domain |
Page origin is not a registrable suffix of the rpId supplied to get() or create() |
400 Bad Request (challenge never issued) |
SecurityError |
§5.1.3 step 3 — rpId is not a registrable domain suffix of the origin |
rpId contains a protocol prefix (https://) or a subdomain not covered by the page origin |
400 Bad Request |
InvalidStateError |
§5.1.3 step 4 — excludeCredentials matches a credential already bound to this rpId |
Phishing site attempts re-registration with a user.id already enrolled on the legitimate origin |
409 Conflict |
| Server-side origin rejection | §7.2 step 13 — clientDataJSON.origin must equal the expected origin |
Adversary-in-the-middle proxy submits an assertion with a spoofed Origin header |
403 Forbidden |
The browser fires these errors before the authenticator receives any CTAP2 command, so private keys and UV flag state are never exposed to the attacker.
Root Cause Analysis
1. Origin Mismatch (NotAllowedError)
The browser derives the effective domain from window.location.hostname and validates it against the rpId in the PublicKeyCredentialRequestOptions or PublicKeyCredentialCreationOptions. If the effective domain is not equal to rpId and is not a registrable suffix of it, the Web Authentication API rejects the call immediately.
A phishing site on https://bankofexample-secure.com cannot present rpId: 'bankofexample.com' because bankofexample-secure.com is not a suffix of bankofexample.com. The browser throws NotAllowedError without any CTAP2 traffic.
2. Malformed RP ID (SecurityError)
Including a protocol scheme (https://) or a port number in rpId violates the spec’s registrable domain suffix rule. This is a common configuration mistake in development environments where engineers copy the full window.location.origin string instead of extracting .hostname.
3. Re-registration Attempt (InvalidStateError)
A phishing site may attempt to register a new credential for an existing user.id in hopes of hijacking session state. When excludeCredentials is populated correctly with the user’s existing credential IDs, the authenticator reports a match and the browser surfaces InvalidStateError. Without excludeCredentials, this protection is absent.
4. Adversary-in-the-Middle (AiTM) Proxy
A real-time proxy forwards the legitimate site’s challenge to the victim and relays the response back. The clientDataJSON object the authenticator signs contains the origin the browser observed — which is the phishing proxy’s origin, not the legitimate one. The server-side check of clientDataJSON.origin against the expected RP origin exposes the attack. This is the one failure mode where the authenticator does sign the assertion, but the RP rejects it during authentication verification logic.
Step-by-Step Resolution
Step 1 — Derive rpId from hostname, never from origin
// Correct: hostname only, no protocol, no port
const rpId = new URL(window.location.origin).hostname;
// e.g. "auth.example.com"
// Wrong: causes SecurityError in every browser
const badRpId = window.location.origin; // "https://auth.example.com:8443"
Use rpId consistently in both PublicKeyCredentialCreationOptions (registration) and PublicKeyCredentialRequestOptions (authentication). The value must not change between the two calls for a given credential.
Step 2 — Validate Origin server-side before issuing a challenge
The server must reject challenge requests whose Origin header does not match the registered RP ID before any cryptographic material is generated. This prevents an AiTM attacker from obtaining a valid challenge to relay to the victim.
import { IncomingMessage, ServerResponse } from 'http';
function validateOrigin(req: IncomingMessage, rpId: string): boolean {
const origin = req.headers['origin'];
if (!origin) return false;
const { hostname } = new URL(origin);
// hostname must equal rpId or be a subdomain of it
return hostname === rpId || hostname.endsWith(`.${rpId}`);
}
// In your challenge endpoint:
app.post('/webauthn/challenge', (req, res) => {
if (!validateOrigin(req, 'example.com')) {
res.status(400).json({ error: 'Origin mismatch' });
return;
}
// ... generate and store challenge
});
Step 3 — Verify clientDataJSON.origin during assertion
The server must re-verify clientDataJSON.origin against the expected origin on every authentication assertion, even after a valid challenge round-trip. This is the primary defence against AiTM proxies.
import { decode as cborDecode } from 'cbor';
import { createHash } from 'crypto';
interface ClientDataJSON {
type: string;
challenge: string;
origin: string;
crossOrigin: boolean;
}
function verifyClientData(
clientDataB64: string,
expectedOrigin: string,
expectedChallenge: Uint8Array
): void {
const clientData: ClientDataJSON = JSON.parse(
Buffer.from(clientDataB64, 'base64url').toString('utf8')
);
if (clientData.origin !== expectedOrigin) {
throw new Error(`Origin mismatch: got ${clientData.origin}, expected ${expectedOrigin}`);
}
const challengeBytes = Buffer.from(clientData.challenge, 'base64url');
if (!challengeBytes.equals(Buffer.from(expectedChallenge))) {
throw new Error('Challenge mismatch — possible replay or AiTM attack');
}
if (clientData.type !== 'webauthn.get') {
throw new Error(`Unexpected clientData.type: ${clientData.type}`);
}
}
Step 4 — Populate excludeCredentials on every registration request
Fetch the user’s existing credential IDs from the database and include them in every registration options payload. This prevents silent re-registration attacks.
import { generateRegistrationOptions } from '@simplewebauthn/server';
async function buildRegistrationOptions(userId: string, rpId: string, rpName: string) {
const existingCredentials = await db.query(
'SELECT credential_id, transports FROM credentials WHERE user_id = $1',
[userId]
);
return generateRegistrationOptions({
rpName,
rpID: rpId,
userID: userId,
userName: await db.getUserEmail(userId),
excludeCredentials: existingCredentials.rows.map(row => ({
id: Buffer.from(row.credential_id, 'base64url'),
type: 'public-key' as const,
transports: row.transports ?? ['internal', 'hybrid']
})),
authenticatorSelection: {
userVerification: 'required',
residentKey: 'preferred'
}
});
}
Step 5 — Set userVerification: 'required' in all assertion requests
Permitting userVerification: 'discouraged' means the UV flag in authenticatorData may be 0, which allows relay attacks where an attacker forwards a legitimate assertion from another session. Requiring UV ensures the authenticator re-verifies the user locally for each assertion.
const authOptions = await generateAuthenticationOptions({
rpID: rpId,
allowCredentials: userCredentials,
userVerification: 'required' // never 'discouraged' in production
});
Inline Diagram — Origin Binding Flow
The diagram below shows why a phishing origin is blocked at the browser layer before any CTAP2 traffic reaches the authenticator.
Verification and Testing
Playwright test — origin mismatch triggers NotAllowedError
import { test, expect } from '@playwright/test';
test('WebAuthn blocks assertion on mismatched rpId', async ({ page }) => {
await page.goto('https://phishing-simulator.test');
const errorName = await page.evaluate(async (): Promise<string> => {
try {
await navigator.credentials.get({
publicKey: {
challenge: crypto.getRandomValues(new Uint8Array(32)),
rpId: 'legitimate-production.com', // intentional origin mismatch
allowCredentials: [],
userVerification: 'required'
}
});
return 'no-error';
} catch (err) {
return (err as DOMException).name;
}
});
expect(errorName).toBe('NotAllowedError');
});
Node.js unit test — clientDataJSON.origin server-side check
import { describe, it, expect } from 'vitest';
import { verifyClientData } from '../src/webauthn/verify';
describe('clientDataJSON origin verification', () => {
it('rejects a proxied origin', () => {
const spoofedClientData = Buffer.from(JSON.stringify({
type: 'webauthn.get',
challenge: 'dGVzdC1jaGFsbGVuZ2U',
origin: 'https://phishing-proxy.com',
crossOrigin: false
})).toString('base64url');
expect(() =>
verifyClientData(spoofedClientData, 'https://example.com', new Uint8Array(16))
).toThrow('Origin mismatch');
});
});
OpenSSL — manually inspect clientDataJSON
# Decode base64url-encoded clientDataJSON from a captured assertion
echo -n "<base64url_value>" | base64 -d | python3 -m json.tool
# Confirm "origin" matches your expected RP origin
Pitfalls Specific to This Sub-Topic
1. Hardcoding rpId as a string literal in code
When a domain changes (e.g. a rebranding or subdomain shift), a hardcoded rpId silently breaks registration for all new users while existing credentials stop authenticating. Always derive rpId from configuration or window.location.hostname, and validate it against the Origin header server-side.
2. Omitting excludeCredentials on registration
Without excludeCredentials, a phishing site can call navigator.credentials.create() with the victim’s user.id on a domain that happens to share the same RP ID (e.g. a subdomain the attacker controls that satisfies the registrable suffix rule). Populate excludeCredentials on every registration request from the list of credential IDs already stored for that user.
3. Accepting userVerification: 'discouraged' in production assertions
Relay attacks become viable when UV is not required: an attacker can forward an assertion captured from one session to authenticate as the user in another session. The challenge-response authentication flow requires a fresh, server-generated challenge bound to a single session — but without UV enforcement, replayed biometric prompts from the same authenticator can satisfy it.
Related
- Understanding WebAuthn vs FIDO2 Architecture — parent cluster covering RP ID, CTAP2, and the full protocol trust model
- The Challenge-Response Authentication Flow — how CSPRNG challenges and signCount protect against replay attacks
- Attestation vs Assertion Explained — how attestationObject and authenticatorData are structured and verified
- WebAuthn Security Boundaries for Enterprise Apps — enterprise trust model, AAGUID policy, and MDS3 metadata verification
- Comparing WebAuthn to Traditional OAuth Flows — why bearer tokens and redirect-based flows remain susceptible to phishing where WebAuthn is not