Fix: Passkey / WebAuthn Not Working — rpId, Origin, Conditional UI, and Cross-Device Sign-In
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:
rpIdmismatch. The Relying Party ID must equal the current domain or be a registrable suffix. If your app runs atapp.example.com,rpId: "example.com"works butrpId: "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. Plainhttp://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 calledget(...)withmediation: "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 localhostFor 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://...→ workshttp://localhost→ works (special case for dev)http://yourdeployed.com→SecurityError
For local dev:
- Use
http://localhost:3000(not127.0.0.1). - Some browsers also accept
http://*.localhost(e.g.http://app.localhost). - For HTTPS testing locally, use
mkcertor a tunnel likengrok.
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:
autocomplete="webauthn"must be in the input’s autocomplete attribute (alongsideusername).- Call
get(...)immediately on page load — not on click. The promise sits open; the browser shows the dropdown when the user focuses the input. 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. - Timeout —
timeoutfield 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/serverBrowser (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:
- Desktop browser shows a QR code.
- User scans it with their phone (camera or paired Bluetooth).
- Phone authenticates with its biometric.
- Phone sends an encrypted credential to the desktop via Bluetooth/network proxy.
- 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
authenticatorAttachmentchoices 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.expectedOriginmust match exactly: scheme, host, port.https://app.example.com:443andhttps://app.example.comare the same;https://example.comis 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/backupStatenot 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.
excludeCredentialsfilters them out during registration. For sign-in, leaveallowCredentialsempty (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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Deno PermissionDenied — Missing --allow-read, --allow-net, and Other Flags
How to fix Deno PermissionDenied (NotCapable in Deno 2) errors — the right permission flags, path-scoped permissions, deno.json permission sets, and the Deno.permissions API.
Fix: jose JWT Not Working — Token Verification Failing, Invalid Signature, or Key Import Errors
How to fix jose JWT issues — signing and verifying tokens with HS256 and RS256, JWK and JWKS key handling, token expiration, claims validation, and edge runtime compatibility.
Fix: Bun Bundler Not Working — Targets, Format, Externals, Macros, and Code Splitting
How to fix bun build errors — target (browser/bun/node) mismatch, format esm/cjs/iife, externals not respected, Bun macros at compile time, splitting and chunks, plugin API, and Bun.build vs CLI.
Fix: Bun Shell Not Working — $ Template Quoting, Pipes, Exit Codes, and Cross-Platform Scripts
How to fix Bun Shell errors — $ template auto-escape vs raw strings, piping with pipe() vs |, throws on non-zero exit, cwd/env scoping, glob expansion differences, and Windows path handling.