Skip to content

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

FixDevs · (Updated: )

Part of:  JavaScript & TypeScript Errors

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".

A second class of issues lives in the two-step credential lifecycle. Registration (create) and authentication (get) are separate flows, each with its own options request, signed response, and verification. If a credential is “lost” between the browser saving it and the server storing it — usually a verify endpoint returning 200 before the DB write succeeded — the user holds a credential your server doesn’t know. Their next sign-in returns a generic NotAllowedError. Make verify return 200 only after the credential is durably stored.

A third class is the difference between platform authenticators (iCloud Keychain, Windows Hello, Google Password Manager) and roaming authenticators (YubiKey, security keys). Platform authenticators sync across devices, store discoverable credentials by default, and use biometrics. Roaming authenticators are per-device with variable user-verification support. Code that hard-codes userVerification: "required" and residentKey: "required" rejects most pre-YK5 YubiKeys. Use "preferred" on both fields and validate the result on the server.

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.

WebAuthn vs OAuth vs SAML vs OIDC: Which Auth Standard Fits Your Use Case?

Passkey / WebAuthn is often discussed in the same breath as OAuth, OIDC, and SAML, but they answer different questions. Mixing them up leads to either over-engineering or shipping a flow that doesn’t do what you think it does.

WebAuthn / FIDO2 answers “how does the user prove they hold a key on their device?” It’s a credential format and a browser API for public-key authentication. No identity provider, no token issuance, no SSO. The browser asks the authenticator to sign a challenge with the private key for this site; your server verifies against the stored public key. Passkey is the marketing name for synced WebAuthn credentials backed by iCloud Keychain, Google Password Manager, or 1Password.

OAuth 2.0 answers “how can my app act on behalf of a user at another service?” The user authenticates with the other service (Google, GitHub), which gives your app a token to call that service’s APIs. Not designed for identity — “sign in with Google” via OAuth alone is a misuse. Use OAuth when you need third-party API access.

OIDC (OpenID Connect) is the “OAuth done right for identity” layer. Built on OAuth 2.0, adds an id_token (signed JWT with user claims) and a discovery document. “Sign in with Google” is technically OIDC. Use OIDC for SSO without managing passwords yourself.

SAML 2.0 is the enterprise SSO protocol — XML-based, browser-redirect-driven. Still ubiquitous (Okta, Azure AD, ADFS) because it predates OIDC. Pick SAML when enterprise customers require it.

FIDO2 is the credential layer under WebAuthn. WebAuthn is the browser API; passkey is the marketing umbrella for synced FIDO2 credentials. Hardware keys (YubiKey, Titan) are roaming; platform authenticators (Touch ID, Windows Hello, Android biometric) are device-bound but sync via the OS password manager.

These layers compose, not compete. A typical 2026 stack: OIDC for SSO between IdP and your app, passkey / WebAuthn for the primary credential at the IdP, OAuth for third-party API access after sign-in. Each layer answers a different question.

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.
  • InvalidStateError on registration. The browser found an existing credential for this rpId and the new registration’s excludeCredentials includes it. This usually means the user is re-registering. Either skip the create flow when they have a credential or pass the existing credential IDs in excludeCredentials so the browser warns them properly.
  • Passkey UI appears but sign-in fails silently after picking. The returned credentialId doesn’t match any stored credential — usually because you base64url-encoded the ID one way on register and another way on verify. Always treat credential IDs as opaque byte strings and use the same encoding (base64url, no padding) end-to-end.
  • Test environments share passkeys with production. A passkey saved against app.example.com with rpId: "example.com" also appears on staging.example.com. Either use a separate apex per environment or use the subdomain as the rpId in staging so credentials don’t bleed across.
  • Account recovery story broken. Users lose their device, lose the passkey, and have no fallback. Always pair passkey sign-in with an email-based recovery flow, a backup TOTP, or a second registered credential. A passkey-only account with no recovery is one lost phone away from a support ticket.

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