Fix: Passkey / WebAuthn Not Working — rpId, Origin, Conditional UI, and Cross-Device Sign-In
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:
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".
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 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.
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
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.
InvalidStateErroron registration. The browser found an existing credential for thisrpIdand the new registration’sexcludeCredentialsincludes 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 inexcludeCredentialsso the browser warns them properly.- Passkey UI appears but sign-in fails silently after picking. The returned
credentialIddoesn’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.comwithrpId: "example.com"also appears onstaging.example.com. Either use a separate apex per environment or use the subdomain as therpIdin 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.
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: pnpm Catalog Protocol Not Working — Cannot Find Catalog, Resolution Errors, and Lockfile Issues
Fix pnpm 9.5+ catalog protocol errors — Cannot find catalog default, ERR_PNPM_CATALOG_ENTRY_INVALID_SPEC, stale lockfile state, and tool incompatibility with catalog: references in monorepos.
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.