Skip to content

Fix: Passkey / WebAuthn Not Working — rpId, Origin, Conditional UI, and Cross-Device Sign-In

FixDevs ·

Quick Answer

How to fix passkey and WebAuthn errors — rpId mismatch, NotAllowedError, Conditional UI autofill not showing, attestation vs assertion, Safari userVerification quirks, and SimpleWebAuthn library integration.

The Error

You call navigator.credentials.create(...) and Safari throws:

NotAllowedError: The relying party ID is not a registrable domain suffix of, 
nor equal to the current domain.

Or registration succeeds but sign-in returns this:

NotAllowedError: The operation either timed out or was not allowed.

Or the “passkey suggestion” autofill never appears in the username field:

<input type="text" name="username" autocomplete="username webauthn" />
<!-- No passkey chip in the autofill dropdown. -->

Or the credential works on Chrome but Safari users get:

SecurityError: The operation is insecure.

Why This Happens

WebAuthn / passkeys use public-key cryptography between the browser, the platform authenticator (iCloud Keychain, Windows Hello, Android), and your server. Most failures map to one of:

  • rpId mismatch. The Relying Party ID must equal the current domain or be a registrable suffix. If your app runs at app.example.com, rpId: "example.com" works but rpId: "other.com" fails. Subdomains can use the parent domain; you can’t cross sites.
  • HTTPS required. WebAuthn refuses to work on non-HTTPS origins except localhost. Plain http:// on a deployed site → SecurityError.
  • User verification options. Setting userVerification: "required" forces biometric/PIN. Some authenticators don’t support it. Setting "discouraged" skips the prompt entirely. The defaults vary by browser.
  • Conditional UI is a separate feature from regular WebAuthn — it only shows passkey suggestions in autofill when both the input has autocomplete="webauthn" and you’ve called get(...) with mediation: "conditional".

Fix 1: Set rpId Correctly

The rpId is your apex domain or a parent of the current page’s domain:

// Page is at https://app.example.com
// Valid:
rpId: "app.example.com"     // exact match
rpId: "example.com"          // parent (registrable suffix)

// Invalid:
rpId: "other.example.com"    // sibling — different domain
rpId: "example.org"          // wrong TLD
rpId: "localhost"            // ok ONLY when page is on localhost

For multi-subdomain apps, use the apex so credentials work across app.example.com, dashboard.example.com, etc.:

const publicKey = await navigator.credentials.create({
  publicKey: {
    rp: {
      id: "example.com",
      name: "Example",
    },
    user: {
      id: new TextEncoder().encode(userId),
      name: userEmail,
      displayName: userName,
    },
    challenge: challengeBytes,
    pubKeyCredParams: [
      { type: "public-key", alg: -7 },   // ES256
      { type: "public-key", alg: -257 }, // RS256
    ],
    authenticatorSelection: {
      residentKey: "preferred",
      userVerification: "preferred",
    },
    timeout: 60_000,
    attestation: "none",
  },
});

Pro Tip: Use rpId: "example.com" (the apex) in production. It costs nothing and gives you flexibility to add subdomains later without re-enrolling users.

Fix 2: Use HTTPS Everywhere (Except localhost)

WebAuthn refuses non-secure contexts:

  • https://... → works
  • http://localhost → works (special case for dev)
  • http://yourdeployed.comSecurityError

For local dev:

  • Use http://localhost:3000 (not 127.0.0.1).
  • Some browsers also accept http://*.localhost (e.g. http://app.localhost).
  • For HTTPS testing locally, use mkcert or a tunnel like ngrok.

For staging and production, terminate TLS at your CDN or load balancer. WebAuthn doesn’t care about the cert chain — only that window.isSecureContext is true.

Fix 3: Use mediation: "conditional" for Autofill UI

Conditional UI shows passkey suggestions in the browser’s autofill dropdown. Two parts required:

<input
  type="text"
  name="username"
  autocomplete="username webauthn"
  placeholder="Email"
/>
// At page load — without await:
const abortController = new AbortController();

navigator.credentials.get({
  publicKey: {
    challenge: challengeBytes,
    rpId: "example.com",
    userVerification: "preferred",
  },
  mediation: "conditional",
  signal: abortController.signal,
}).then((credential) => {
  // User picked a passkey from the autofill UI.
  signInWithCredential(credential);
}).catch((err) => {
  if (err.name !== "AbortError") console.error(err);
});

// Abort when navigating away or showing a different form:
// abortController.abort();

Three things to get right:

  1. autocomplete="webauthn" must be in the input’s autocomplete attribute (alongside username).
  2. Call get(...) immediately on page load — not on click. The promise sits open; the browser shows the dropdown when the user focuses the input.
  3. mediation: "conditional" changes the API behavior — no modal, just the autofill dropdown.

Common Mistake: Calling get({ mediation: "conditional" }) from a click handler. By then the dropdown is already gone. Call it at page render.

Fix 4: NotAllowedError Catch-All

NotAllowedError is a deliberately vague message. Common causes:

  • User cancelled — clicked away from the passkey prompt.
  • No matching credential — sign-in flow doesn’t find a credential on this device for this rpId.
  • Timeouttimeout field passed in ms; defaults vary by browser.
  • rpId mismatch — see Fix 1.
  • userVerification: "required" on an authenticator that can’t do it.

Distinguish them with logging:

try {
  const credential = await navigator.credentials.get({ publicKey: {...} });
} catch (err) {
  if (err instanceof DOMException) {
    console.error(`${err.name}: ${err.message}`);
  }
  // Show user-friendly: "Couldn't sign in with passkey. Try email instead?"
}

Don’t try to recover automatically — let the user fall back to email/password. A passkey attempt that auto-retries is worse UX than one that gracefully gives up.

Fix 5: Use SimpleWebAuthn on Both Sides

Writing WebAuthn from scratch involves CBOR decoding, attestation parsing, signature verification — a lot of bytes-level code. Use @simplewebauthn/browser and @simplewebauthn/server:

npm install @simplewebauthn/browser @simplewebauthn/server

Browser (registration):

import { startRegistration } from "@simplewebauthn/browser";

const optionsJSON = await fetch("/api/passkey/register/options").then((r) => r.json());
const attResp = await startRegistration({ optionsJSON });
const verification = await fetch("/api/passkey/register/verify", {
  method: "POST",
  body: JSON.stringify(attResp),
}).then((r) => r.json());

Server (Node example):

import { generateRegistrationOptions, verifyRegistrationResponse } from "@simplewebauthn/server";

app.get("/api/passkey/register/options", async (req, res) => {
  const options = await generateRegistrationOptions({
    rpName: "Example",
    rpID: "example.com",
    userID: new TextEncoder().encode(userId),
    userName: userEmail,
    attestationType: "none",
    excludeCredentials: existingCredentials.map((c) => ({
      id: c.credentialId,
      transports: c.transports,
    })),
    authenticatorSelection: {
      residentKey: "preferred",
      userVerification: "preferred",
    },
  });
  // Save options.challenge somewhere keyed to this session.
  res.json(options);
});

app.post("/api/passkey/register/verify", async (req, res) => {
  const expectedChallenge = await loadChallengeForSession(req);
  const verification = await verifyRegistrationResponse({
    response: req.body,
    expectedChallenge,
    expectedOrigin: "https://app.example.com",
    expectedRPID: "example.com",
  });
  if (!verification.verified) return res.status(400).json({ error: "verification failed" });
  await saveCredential({
    userId,
    credentialId: verification.registrationInfo.credential.id,
    publicKey: verification.registrationInfo.credential.publicKey,
    counter: verification.registrationInfo.credential.counter,
  });
  res.json({ ok: true });
});

The library handles CBOR decoding, attestation verification, transport tracking, and the assorted compatibility quirks across browsers.

Pro Tip: Pin @simplewebauthn/browser and @simplewebauthn/server to the same major version. Their wire format and option shapes evolve together.

Fix 6: Sign-In With Discoverable Credentials

For “usernameless” sign-in (passkey-first login flow), use a resident/discoverable key and skip the username step:

// Server:
const options = await generateAuthenticationOptions({
  rpID: "example.com",
  userVerification: "preferred",
  // No allowCredentials — let the browser show all available passkeys.
});

// Browser:
const authResp = await startAuthentication({ optionsJSON: options });

The browser surfaces a list of passkeys registered for this rpId. User picks one, signs, and the server verifies based on the returned credentialId.

For this to work, registration must have used residentKey: "required":

await generateRegistrationOptions({
  // ...
  authenticatorSelection: {
    residentKey: "required",
    userVerification: "required",
  },
});

residentKey: "preferred" lets the authenticator decide; "required" forces a discoverable key (some older authenticators reject this).

Fix 7: Cross-Device Sign-In (Hybrid Flow)

A user’s passkey on their phone can sign them in on a desktop they don’t have a passkey on. This is the “hybrid” or “cross-device” flow:

  1. Desktop browser shows a QR code.
  2. User scans it with their phone (camera or paired Bluetooth).
  3. Phone authenticates with its biometric.
  4. Phone sends an encrypted credential to the desktop via Bluetooth/network proxy.
  5. Desktop completes the sign-in.

You don’t have to build any of this — Chrome and Safari handle the QR + Bluetooth handshake automatically when the user has no local passkey for the site. Your code just calls navigator.credentials.get(...) and waits.

Common Mistake: Setting transports: ["internal"] on excludeCredentials to block hybrid. That excludes existing passkeys from registration, not sign-in. To allow only platform passkeys (no hybrid), use authenticatorAttachment: "platform". To allow hybrid, use "cross-platform" or omit.

Fix 8: Counter Replay Protection

WebAuthn includes a counter that some authenticators increment per signing. Some don’t (especially platform authenticators on iCloud Keychain, Windows Hello). Don’t reject sign-ins based on counter staying at 0:

const verification = await verifyAuthenticationResponse({
  response: req.body,
  expectedChallenge,
  expectedOrigin: "https://app.example.com",
  expectedRPID: "example.com",
  credential: {
    id: storedCredential.credentialId,
    publicKey: storedCredential.publicKey,
    counter: storedCredential.counter,
  },
});

if (verification.verified) {
  // Update the stored counter (use the verified new value):
  await updateCounter(storedCredential.credentialId, verification.authenticationInfo.newCounter);
}

If the new counter is 0 and you stored 0, that’s not a replay — it’s a synced credential that doesn’t track. Accept and move on.

For Yubikeys and other hardware tokens that do track counter, a non-incrementing counter (new < stored) is a sign of a cloned key. Treat as suspicious.

Still Not Working?

A few less-obvious failures:

  • Works on macOS Safari but not iOS Safari (or vice versa). Some authenticatorAttachment choices restrict device types. Test on both. If you don’t need cross-device, set "platform"; for everything, omit it.
  • The challenge from the registration was not the expected value. You’re verifying against the wrong stored challenge. Make sure the challenge sent by the server is tied to the user’s session and consumed after verification (don’t reuse it).
  • Expected origin to be... mismatch. expectedOrigin must match exactly: scheme, host, port. https://app.example.com:443 and https://app.example.com are the same; https://example.com is different.
  • Username conditional UI doesn’t trigger after rejecting one passkey. The user picked “Other options” — the conditional flow is aborted. Show your normal sign-in form and let them choose.
  • backupEligible / backupState not synced. Newer WebAuthn signals whether the credential can be (or is) synced across devices. Store these on your server so you can warn users about non-syncable credentials.
  • Yubikey works but iCloud passkey fails. Yubikey is roaming (no biometric required by default); iCloud passkey requires user verification. Set userVerification: "preferred" so both work.
  • Multiple credentials per user not shown in chooser. excludeCredentials filters them out during registration. For sign-in, leave allowCredentials empty (or pass the right list).
  • Cookie/session lost between credential creation and verification. SameSite=Strict on session cookies can break the multi-step flow if the user came via a redirect. Use SameSite=Lax for auth cookies.

For related authentication and security issues, see Auth.js not working, Clerk not working, Better Auth not working, and Cors access-control-allow-origin.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles