Topic: MCP server WebAuthn / FIDO2 security

MCP server WebAuthn security — FIDO2 authentication risks and defenses

WebAuthn (FIDO2) is increasingly used as the authentication layer for MCP servers deployed as web services — passkeys eliminate password phishing entirely by binding credentials cryptographically to the origin. But WebAuthn security depends on correct implementation of six mandatory verification steps. Each step that is skipped or weakened opens a distinct attack vector: origin spoofing, challenge replay, counter rollback, attestation bypass, and credential ID substitution. MCP servers that implement "WebAuthn-inspired" flows without full spec compliance are vulnerable to the attacks WebAuthn was designed to prevent.

Why WebAuthn in MCP servers

MCP servers deployed as shared team infrastructure — a self-hosted code analysis server, a corporate data access server, a multi-user document server — increasingly add WebAuthn authentication because:

These properties are real — when the implementation is correct. The WebAuthn spec (W3C Level 3) specifies precise verification algorithms. Libraries like @simplewebauthn/server (Node.js), webauthn4j (Java), and py_webauthn (Python) implement the full verification flow. Problems arise when developers implement their own verification logic or configure libraries with security-reducing options.

Verification step 1: Origin binding

During registration and authentication, the authenticator includes the rpId (relying party ID, typically the domain) in the signed assertion. The server must verify that this rpId matches its own expected value exactly.

Incorrect implementation:

// Dangerous: accepts any rpId that "includes" the expected domain
async function verifyAssertion(response) {
  const clientData = JSON.parse(atob(response.clientDataJSON));
  // This check is wrong — substring match is not the same as exact match
  if (!clientData.origin.includes('mcp.corp.internal')) {
    throw new Error('Origin mismatch');
  }
  // ...
}

An attacker who controls evil-mcp.corp.internal or mcp.corp.internal.attacker.com can forge an origin that passes the substring check. The correct check uses exact match:

// Correct: exact origin match
const EXPECTED_ORIGIN = 'https://mcp.corp.internal';

async function verifyAssertion(response) {
  const clientData = JSON.parse(atob(response.clientDataJSON));
  if (clientData.origin !== EXPECTED_ORIGIN) {
    throw new Error('Origin mismatch');
  }

  // Also verify rpIdHash in authenticatorData matches SHA-256 of your rpId
  const authData = Buffer.from(response.authenticatorData, 'base64url');
  const rpIdHash = authData.slice(0, 32);
  const expectedRpIdHash = crypto.createHash('sha256')
    .update('mcp.corp.internal')
    .digest();
  if (!rpIdHash.equals(expectedRpIdHash)) {
    throw new Error('rpId hash mismatch');
  }
}

Verification step 2: Challenge replay prevention

The server issues a fresh cryptographically random challenge for each authentication attempt. The authenticator signs this challenge. The server must verify:

  1. The challenge in the signed clientDataJSON matches what the server issued
  2. The challenge has not been used before (one-time use)
  3. The challenge has not expired

If the server skips the one-time use check, an attacker who intercepts a valid authentication response (via network interception or server log extraction) can replay it to authenticate as the victim:

// Correct challenge management: issue, store, verify, consume
async function createChallenge(userId: string): Promise {
  const challenge = crypto.randomBytes(32).toString('base64url');
  const expiresAt = Date.now() + 5 * 60 * 1000; // 5-minute window
  await db.run(
    'INSERT INTO webauthn_challenges (challenge, user_id, expires_at, used) VALUES (?, ?, ?, 0)',
    [challenge, userId, expiresAt]
  );
  return challenge;
}

async function consumeChallenge(challenge: string, userId: string): Promise {
  const result = await db.run(
    'UPDATE webauthn_challenges SET used = 1 WHERE challenge = ? AND user_id = ? AND expires_at > ? AND used = 0',
    [challenge, userId, Date.now()]
  );
  if (result.changes === 0) {
    throw new Error('Challenge invalid, expired, or already used');
  }
}

Verification step 3: Signature counter (rollback prevention)

Hardware authenticators increment a counter on each use. The server stores the last-seen counter value and rejects any assertion where the new counter value is not greater than the stored value. This detects cloned credentials:

async function verifyAndUpdateCounter(credentialId: string, newCounter: number): Promise {
  const credential = await db.get(
    'SELECT counter FROM webauthn_credentials WHERE credential_id = ?',
    [credentialId]
  );

  if (!credential) throw new Error('Unknown credential');

  // Counter must strictly increase (or be 0 for software authenticators that don't use counters)
  if (credential.counter > 0 && newCounter <= credential.counter) {
    // Counter did not increase — possible credential clone
    // Log a security event and reject
    await logSecurityEvent('webauthn-counter-rollback', { credentialId, stored: credential.counter, received: newCounter });
    throw new Error('Authenticator counter decreased — possible credential clone detected');
  }

  await db.run(
    'UPDATE webauthn_credentials SET counter = ? WHERE credential_id = ?',
    [newCounter, credentialId]
  );
}

Note that software authenticators (platform passkeys on phones and laptops) often use counter = 0, indicating "no counter." The check must accept counter = 0 as a valid "no counter" signal and only enforce the strictly-increasing invariant when the previous counter was non-zero.

Verification step 4: User verification flag

The UV (user verification) bit in authenticator data indicates that the authenticator required a PIN, biometric, or other user gesture — not just presence (touching a key). MCP servers that require strong authentication must verify the UV bit is set:

function verifyAuthenticatorFlags(authData: Buffer, requireUserVerification: boolean): void {
  const flags = authData[32]; // Flags byte at offset 32

  const UP = (flags & 0x01) !== 0; // User Presence
  const UV = (flags & 0x04) !== 0; // User Verification

  if (!UP) {
    throw new Error('User presence flag not set');
  }

  if (requireUserVerification && !UV) {
    // Authenticator confirmed presence but did not verify the user (no PIN/biometric)
    throw new Error('User verification required but not performed');
  }
}

MCP servers that call the verification library without setting requireUserVerification: true in the options accept presence-only authentication — a YubiKey touch without a PIN satisfies the check, which may be insufficient for high-privilege tool access.

Using a verified library correctly

The safest approach is using a well-maintained library with all verification steps enabled:

import { verifyAuthenticationResponse } from '@simplewebauthn/server';

const verification = await verifyAuthenticationResponse({
  response: authenticationResponse,
  expectedChallenge,                // Must be consumed after verification
  expectedOrigin: 'https://mcp.corp.internal',
  expectedRPID: 'mcp.corp.internal',
  authenticator: {
    credentialID: storedCredentialId,
    credentialPublicKey: storedPublicKey,
    counter: storedCounter,
  },
  requireUserVerification: true,   // Enforce UV flag — do not set to false
});

if (!verification.verified) {
  throw new Error('WebAuthn verification failed');
}

// Update counter after successful verification
await updateCounter(credentialId, verification.authenticationInfo.newCounter);
// Consume challenge after successful verification
await consumeChallenge(expectedChallenge, userId);

SkillAudit checks for requireUserVerification: false in WebAuthn configuration, string-based origin comparisons, and missing counter update logic. These are High or Medium findings depending on the MCP server's declared access scope.

Audit your MCP server's WebAuthn implementation for origin binding and verification gaps.

Run a free audit →