Resolving WebAuthn SecurityError (RP ID and Origin)

Unlike NotAllowedError, a SecurityError from navigator.credentials.create() or .get() is never ambiguous and never the user’s fault: it means the ceremony’s RP ID is not a registrable-suffix match of the page’s origin, the page is not a secure context, or a cross-origin iframe attempted WebAuthn without permission. This page turns each cause into a deterministic check and a fix. It sits under Debugging WebAuthn Protocol Errors; the RP ID rules themselves come from the relying party and authenticator roles cluster.


Cause Signatures

Cause Reproduces when Fix
RP ID not a suffix of origin rpId is not equal to / parent of location.hostname Set rpId to the shared registrable domain
Insecure origin Page served over HTTP (not localhost) Serve over HTTPS
Cross-origin iframe WebAuthn called in an iframe without publickey-credentials-* permission policy Add the permission policy or move out of the iframe
Trailing-dot / case mismatch rpId has a trailing dot or differing case Normalise rpId to lowercase, no trailing dot

Every row is deterministic — the same input always fails — which is why a SecurityError in production is a config bug to alert on, not a transient the user should retry.


Root Cause Analysis

1. RP ID granularity mismatch. The most frequent cause: rpId is set to example.com while the page runs on app.example.com (valid — example.com is a parent), or rpId is app.example.com while the page is example.com (invalid — a child is not a suffix of the parent’s page). The rule (WebAuthn L2 §5.1.3): rpId must equal the page’s effective domain or be a registrable parent of it.

2. Non-secure context. WebAuthn only runs in a secure context. Any HTTP origin except http://localhost throws SecurityError immediately.

3. Cross-origin embedding. An <iframe> on a different origin cannot call WebAuthn unless the embedding page grants publickey-credentials-get / publickey-credentials-create via Permissions Policy.

4. Normalisation slips. A trailing dot (example.com.) or uppercase in rpId fails the suffix comparison.


Step-by-Step Resolution

Step 1 — Validate rpId against the origin deterministically

function rpIdIsValidForOrigin(rpId: string, origin: string): boolean {
  const host = new URL(origin).hostname.toLowerCase();
  const id = rpId.toLowerCase().replace(/\.$/, ''); // strip trailing dot
  return host === id || host.endsWith(`.${id}`);
}
// rpIdIsValidForOrigin('example.com', 'https://app.example.com')  === true
// rpIdIsValidForOrigin('app.example.com', 'https://example.com')  === false → SecurityError

Step 2 — Set rpId to the shared registrable domain

// Serve WebAuthn from app.example.com and accounts.example.com under one RP ID:
const rpID = 'example.com';

Step 3 — Enforce a secure context

Serve production over HTTPS; use localhost (not a LAN IP) for development. Confirm window.isSecureContext === true before calling WebAuthn.

Step 4 — Grant iframe permission or avoid embedding

<!-- Only if WebAuthn must run inside a cross-origin iframe -->
<iframe src="https://auth.example.com" allow="publickey-credentials-get; publickey-credentials-create"></iframe>

Step 5 — Alert, don’t retry

Route SecurityError to engineering telemetry, not a user retry, per the client-side debugging cluster.


Verification and Testing

// Unit-test the suffix rule
expect(rpIdIsValidForOrigin('example.com', 'https://example.com')).toBe(true);
expect(rpIdIsValidForOrigin('example.com', 'https://sub.example.com')).toBe(true);
expect(rpIdIsValidForOrigin('example.com', 'https://example.org')).toBe(false);
expect(rpIdIsValidForOrigin('app.example.com', 'https://example.com')).toBe(false);

In the browser, window.isSecureContext must be true. Reproduce the iframe case by embedding your auth page cross-origin without the allow attribute and confirming the SecurityError, then add the permission policy and confirm it clears. A synthetic monitor should assert no SecurityError occurs on any production sign-in surface.


Pitfalls

1. Setting rpId to a subdomain of the page. A child domain is not a suffix of the page’s parent origin — use the shared registrable domain.

2. Testing over a LAN IP. Only localhost is a secure context without HTTPS; a 192.168.x.x origin throws SecurityError.

3. Retrying SecurityError. It is deterministic — fix the config, do not loop.


Related