WebAuthn Security Boundaries for Enterprise Apps

Enterprise deployments expose three hard security boundaries that WebAuthn enforces at the protocol level: the origin boundary (RP ID scoping), the hardware boundary (authenticator isolation), and the policy boundary (UV flag and AAGUID allowlisting). Misconfiguring any one of them undermines phishing resistance, enables credential spoofing, or triggers regulatory audit failures under NIST SP 800-63B (AAL2/AAL3), SOC 2 Type II, and ISO/IEC 27001 Annex A. This page is a focused reference for diagnosing boundary violations and implementing enforcement code — for the full trust model between the relying party and authenticator roles, start there.


Security Boundary Architecture

The diagram below shows the three enforcement layers and where each DOMException fires when a boundary is violated.

WebAuthn Enterprise Security Boundaries Three nested boundary layers for enterprise WebAuthn: the outermost origin boundary (browser/RP ID check), the middle policy boundary (server UV flag and AAGUID allowlist check), and the innermost hardware boundary (authenticator secure element). Violations at each layer produce specific DOMException codes or HTTP error responses. ORIGIN BOUNDARY — browser enforces rp.id vs window.location.hostname Violation → SecurityError (DOMException) POLICY BOUNDARY — server checks UV flag, AAGUID allowlist, signCount Violation → HTTP 401/422; NotAllowedError if UV absent HARDWARE BOUNDARY Authenticator secure element — private key never leaves Authenticator CTAP2 · AAGUID · attestationObject UV flag · signCount · rpIdHash Platform (TPM/SE) or Roaming (CTAP2/USB/NFC) CTAP2 Browser navigator.credentials origin binding HTTPS POST RP Server attestation verify MDS3 · AAGUID policy dashed border = enforcement layer; violation produces the labelled error code

Each boundary is independently enforced: the browser cannot substitute for server-side UV flag verification, and the server cannot substitute for the authenticator’s hardware key isolation.


Exact Error Signatures and Spec Constraints

Scan-match your error against this table before proceeding to root cause analysis.

Error / Code Layer Spec Reference HTTP Status Trigger Condition
SecurityError Origin W3C WebAuthn §13.4.9 400 rp.id is not a registrable-domain suffix of the page origin; non-HTTPS origin; cross-origin iframe without allow="publickey-credentials-get"
NotAllowedError Browser/Policy W3C WebAuthn §5.1.3 400 No transient user activation; UV timed out; MDM policy blocks FIDO2; user cancelled gesture
InvalidStateError Policy W3C WebAuthn §5.1.3 409 credentialId already registered for this (user_id, rp_id) pair
UV flag absent Policy W3C WebAuthn §6.1, bit 2 401 (authenticatorData[32] & 0x04) === 0 when userVerification: 'required' was requested
rpIdHash mismatch Policy W3C WebAuthn §6.1, bytes 0–31 422 SHA-256 of rp.id does not match the first 32 bytes of authenticatorData
AAGUID not in allowlist Policy FIDO MDS3 §3.1 403 Authenticator’s AAGUID absent from enterprise-approved set or MDS3 status is REVOKED
signCount regression Policy W3C WebAuthn §7.2 step 17 401 Incoming signCount ≤ stored value (possible clone or replay)

Root Cause Analysis

Failure mode 1 — RP ID scope violation (SecurityError)

The browser computes the effective domain of window.location.hostname and checks that rp.id is equal to it or a registrable-domain suffix. The check fires before the CTAP2 command is issued, so no authenticator interaction occurs. Common triggers in enterprise environments:

  • rp.id was set to https://corp.example.com (scheme included) instead of corp.example.com
  • A reverse proxy rewrites the Host header, making the effective domain visible to the browser differ from the rp.id baked into the registration options
  • The page is served from https://auth.internal.corp.example.com but rp.id is example.com — valid per the spec, but some enterprise internal CAs reject the Public Suffix List lookup required to verify this

Failure mode 2 — UV flag absent despite userVerification: 'required'

Passing userVerification: 'required' in authenticatorSelection instructs the browser and CTAP2 layer, but it does not guarantee the UV bit in authenticatorData[32]. Older CTAP2.0 implementations on security keys (particularly early YubiKey firmware) may silently complete without biometric or PIN verification if the device has no PIN enrolled. The authenticatorData byte layout must be parsed server-side on every assertion — the browser does not re-validate this for you.

Failure mode 3 — AAGUID outside enterprise allowlist

The AAGUID (16 bytes at authenticatorData[37–52] when the AT flag is set) identifies the authenticator model. Enterprise policies that restrict to hardware-backed authenticators (e.g., YubiKey 5 series, Apple Secure Enclave, Windows Hello TPM) must validate the AAGUID against the FIDO Alliance MDS3 metadata feed. Attestation verification alone does not enforce device model policy — you must also check the AAGUID after the cryptographic verification succeeds.

Failure mode 4 — signCount regression on non-synced credentials

For device-bound authenticators (Windows Hello TPM, hardware security keys), signCount must increment monotonically. A stored value of 5 and an incoming value of 3 signals a cloned authenticator or a replayed authenticatorData blob. Synced passkeys (iCloud Keychain, Google Password Manager) legitimately report signCount = 0 permanently — exempting them from the monotonicity check is correct, but the exemption must be scoped to credentials where the backup_eligible (BE) flag is set.


Step-by-Step Resolution

1 — Validate origin and enforce HTTPS before issuing challenge options

export function enforceOriginBoundary(origin: string, rpId: string): void {
  let url: URL;
  try {
    url = new URL(origin);
  } catch {
    throw new Error('Invalid origin format');
  }

  if (url.protocol !== 'https:') {
    throw new Error(`Non-HTTPS origin rejected: ${url.protocol}`);
  }

  const hostname = url.hostname;
  // rp.id must equal the hostname or be a registrable-domain suffix
  if (hostname !== rpId && !hostname.endsWith('.' + rpId)) {
    throw new Error(
      `Origin hostname "${hostname}" is not a valid suffix of rp.id "${rpId}"`
    );
  }
}

Call this before generating the challenge. Return 400 Bad Request with a structured error body if it throws; do not issue a challenge to an unvalidated origin.

2 — Parse authenticatorData and assert UV flag server-side

import crypto from 'crypto';

export function verifyAuthenticatorDataBoundaries(
  authenticatorData: Buffer,
  rpId: string,
  requireUV: boolean
): void {
  // Bytes 0–31: SHA-256(rpId)
  const rpIdHash = authenticatorData.subarray(0, 32);
  const expectedHash = crypto.createHash('sha256').update(rpId).digest();

  if (!rpIdHash.equals(expectedHash)) {
    throw new Error('rpIdHash mismatch — RP ID binding violated');
  }

  const flags = authenticatorData[32];
  const UP = (flags & 0x01) !== 0; // bit 0: User Presence
  const UV = (flags & 0x04) !== 0; // bit 2: User Verification

  if (!UP) {
    throw new Error('UP flag not set — user presence not confirmed');
  }
  if (requireUV && !UV) {
    throw new Error('UV flag not set — user verification required by enterprise policy');
  }
}

Pass requireUV: true for any authentication context mapped to NIST AAL2 or higher.

3 — Extract AAGUID and enforce enterprise allowlist via MDS3

The AAGUID lives at bytes 37–52 of authenticatorData when the AT flag (bit 6) is set. After cryptographic attestation verification, look it up in your MDS3 cache:

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

/** MDS3 metadata entry shape (simplified) */
interface MetadataEntry {
  aaguid: string;
  statusReports: Array<{ status: string }>;
  description: string;
}

export async function verifyAndEnforceAuthenticatorPolicy(
  attestationResponse: RegistrationResponseJSON,
  expectedChallenge: string,
  rpId: string,
  approvedAaguids: Set<string>,
  mds3Cache: Map<string, MetadataEntry>
): Promise<void> {
  const verification = await verifyRegistrationResponse({
    response: attestationResponse,
    expectedChallenge,
    expectedOrigin: `https://${rpId}`,
    expectedRPID: rpId,
    requireUserVerification: true
  });

  if (!verification.verified || !verification.registrationInfo) {
    throw new Error('Attestation verification failed');
  }

  const { aaguid } = verification.registrationInfo;

  // Policy check 1: AAGUID must be in enterprise-approved set
  if (approvedAaguids.size > 0 && !approvedAaguids.has(aaguid)) {
    throw new Error(`Authenticator AAGUID not in enterprise allowlist: ${aaguid}`);
  }

  // Policy check 2: MDS3 status must not indicate compromise
  const entry = mds3Cache.get(aaguid);
  if (entry) {
    const latestStatus = entry.statusReports.at(-1)?.status ?? '';
    const blockedStatuses = new Set([
      'REVOKED',
      'USER_VERIFICATION_BYPASS',
      'ATTESTATION_KEY_COMPROMISE',
      'USER_KEY_REMOTE_COMPROMISE',
      'USER_KEY_PHYSICAL_COMPROMISE'
    ]);
    if (blockedStatuses.has(latestStatus)) {
      throw new Error(
        `Authenticator "${entry.description}" blocked by MDS3 status: ${latestStatus}`
      );
    }
  }
}

4 — Enforce signCount monotonicity with synced-passkey exemption

export function enforceSignCountPolicy(
  incoming: number,
  stored: number,
  backupEligible: boolean,
  credentialId: string,
  logger: { warn: (msg: object) => void; error: (msg: object) => void }
): boolean {
  // Synced passkeys (BE flag set) legitimately report signCount = 0 permanently
  if (backupEligible && incoming === 0 && stored === 0) {
    return true;
  }

  if (incoming <= stored) {
    logger.error({
      event: 'sign_count_regression',
      credentialId,
      incoming,
      stored,
      action: 'reject_assertion',
      reason: 'possible_clone_or_replay'
    });
    return false;
  }

  return true;
}

5 — Reject cross-origin iframe invocations without explicit permission policy

Cross-origin iframes must declare the publickey-credentials-get Permissions Policy to invoke navigator.credentials.get(). Without it, the browser throws NotAllowedError before any challenge is consumed. Enforce this at the HTTP layer:

// Express middleware — add to every route that serves pages embedding WebAuthn iframes
app.use((req, res, next) => {
  // Allow the current origin and any trusted subdomain to call WebAuthn
  res.setHeader(
    'Permissions-Policy',
    'publickey-credentials-get=(self "https://auth.corp.example.com")'
  );
  res.setHeader(
    'Strict-Transport-Security',
    'max-age=31536000; includeSubDomains; preload'
  );
  next();
});

Verification and Testing

Confirm each boundary is enforced before promoting to production:

For automated conformance testing, the FIDO Alliance WebAuthn Conformance Test Suite includes test vectors for each of these boundary conditions.


Pitfalls

1 — Skipping server-side UV flag verification because userVerification: 'required' was set client-side

Passing userVerification: 'required' in authenticatorSelection is a request, not a guarantee. The server must parse authenticatorData[32] and check bit 2 independently on every registration and assertion. This is especially critical for enterprise SSO flows that rely on NIST SP 800-63B AAL2 compliance — accepting a UV-absent assertion silently downgrades the assurance level without any observable error.

2 — Building an AAGUID allowlist without subscribing to MDS3 status updates

An AAGUID allowlist that is populated once and never refreshed will eventually include authenticators that have been revoked or found to have security vulnerabilities. The FIDO Alliance MDS3 feed publishes status updates when affected firmware is disclosed. Refresh your MDS3 cache on a daily schedule and implement a background job that flags credentials whose AAGUID has transitioned to a blocked status.

3 — Treating backup_eligible = true as a reason to skip signCount enforcement for device-bound roaming keys

The backup_eligible (BE) flag means the credential can be synced, not that it is synced. A YubiKey 5 NFC will never set the BE flag, so a signCount regression on that credential is always a genuine security signal. Apply the synced-passkey exemption only when backup_eligible = true AND signCount = 0 — not to all credentials with BE set that happen to report a non-zero counter.


Related