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.

WebAuthn origin binding — phishing site blocked by browser Sequence diagram showing three actors: Phishing Site, Browser (WebAuthn API), and Authenticator. The phishing site calls navigator.credentials.get with rpId set to the legitimate domain. The browser checks whether the page origin matches the rpId and finds a mismatch. The browser immediately throws NotAllowedError back to the phishing site without contacting the authenticator. Phishing Site Browser (WebAuthn API) Authenticator credentials.get({ rpId: 'legit.com' }) Origin check phish.com ≠ legit.com No CTAP2 command sent (authenticator not contacted) NotAllowedError thrown Private key never leaves authenticator — no COSE key material exposed to phishing site UV flag, signCount, and authenticatorData are never produced for mismatched origins

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