I was in the middle of a client call when the CEO asked why the new password-less login we’d just shipped kept showing a "device not recognized" error for a handful of users. The error string was InvalidStateError: The authenticator was not registered. After a quick look at the console I realized the problem was not a bug in our code but a misunderstanding of how passkeys are supposed to be created and stored. The whole episode reminded me how often developers treat passkeys as just another form field, when in reality they are a completely different authentication primitive built on WebAuthn.

What a passkey actually is

A passkey is a pair of cryptographic keys, a public key stored on the server and a private key kept inside a trusted authenticator (your phone, a hardware security key, or the platform’s secure enclave). The private key never leaves the authenticator, and it is never sent over the network. When you log in, the server sends a challenge, the authenticator signs it with the private key, and the server verifies the signature with the stored public key.

  • publicKeyCredentialCreationOptions tells the browser what kind of credential to create.
  • The authenticator generates a new key pair and returns the public key to the server.
  • Later, publicKeyCredentialRequestOptions is used for authentication, and the authenticator proves it still holds the private key.

Because the private key never travels, a phishing site can’t steal it. Even if a user clicks a malicious link, the attacker can’t get the signed challenge without the genuine authenticator, which only works for the original domain.

The WebAuthn flow in plain English

  1. Registration, The user clicks Create Passkey.
    • Your frontend calls navigator.credentials.create() with a publicKey object.
    • The browser talks to the authenticator, which creates a fresh key pair.
    • The authenticator returns a PublicKeyCredential containing the public key and an attestation statement.
    • Your server validates the attestation, extracts the public key, and stores it linked to the user.
  2. Login, The user clicks Sign in with Passkey.
    • Your frontend calls navigator.credentials.get() with a publicKey challenge.
    • The authenticator signs the challenge with the private key and returns the assertion.
    • The server verifies the signature using the stored public key. If it matches, the user is authenticated.

If any step fails, the browser throws an error you can catch and translate into a user-friendly message.

Why phishing becomes impossible

Phishing works by stealing something the victim types, a password, OTP, or token. With passkeys the secret never leaves the authenticator, and the authenticator will only sign challenges that originate from the exact origin (scheme + host + port) that created the credential. If a user lands on evil-site.com, the browser will refuse to use a passkey that was registered for example.com. The user sees a prompt that says "Sign in with your passkey for example.com", a clear visual cue that the site is not the legitimate one.

Even a sophisticated man-in-the-middle attack cannot replay the signed challenge because each challenge is a random nonce generated by the server for that session. The only way to bypass the system is to compromise the authenticator itself, which is far harder than stealing a password.

Implementing passkeys with minimal friction

When I first added passkeys to a WooCommerce store, I tried to copy a generic WebAuthn snippet I found online. It worked on my laptop but broke on Android because I had hard-coded userVerification: "required" while the phone’s biometric sensor was set to optional. The fix was simple: let the client decide based on navigator.credentials capabilities.

// registration.js, create a passkey
async function registerPasskey(username) {
  const options = await fetch('/webauthn/register', {
    method: 'POST',
    body: JSON.stringify({ username })
  }).then(r => r.json());

  const credential = await navigator.credentials.create({ publicKey: options });

  // Send the attestation to the server for verification
  await fetch('/webauthn/verify-registration', {
    method: 'POST',
    body: JSON.stringify(credential)
  });
}
  • fetch('/webauthn/register') gets the creation options from the server.
  • navigator.credentials.create hands the options to the authenticator.
  • The returned credential contains response.attestationObject and response.clientDataJSON which you must forward to the server.
  • The server validates the attestation and stores the public key.

A few practical warnings:

  • Never store the raw credential object in a cookie or localStorage; it contains binary data that can be large and may leak.
  • Always verify the rpId on the server matches your domain; otherwise a rogue sub-domain could register a credential that works elsewhere.
  • Handle InvalidStateError gracefully, it means the user is trying to register a credential that already exists or the authenticator is locked.

Common pitfalls and how to avoid them

1. Mismatched rpId

The rpId (relying party identifier) defaults to the effective domain. If you serve your site on app.example.com but register with example.com, the credential will be scoped to the parent domain and work across sub-domains. That’s fine for a single-page app, but if you later move to a different sub-domain you’ll see InvalidStateError. Always decide early whether you want a site-wide or sub-domain-specific credential.

2. User verification settings

userVerification can be required, preferred, or discouraged. Requiring it forces a biometric or PIN every login, which can frustrate users on devices without biometrics. I usually start with preferred and let the authenticator decide.

3. Forgetting to base64-url-encode binary data

The WebAuthn spec uses ArrayBuffer for most fields. When you send them to the server as JSON you must base64-url-encode them. A missing step here leads to DataError: The data is not a valid base64url string. I added a tiny helper:

function bufferToBase64url(buf) {
  return btoa(String.fromCharCode(...new Uint8Array(buf)))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
  • btoa converts binary to base64.
  • The replace calls turn it into URL-safe format.

When passkeys aren’t enough

Passkeys solve credential theft, but they don’t replace all security layers. You still need:

  • Rate limiting, an attacker could flood login attempts and lock users out.
  • Device loss handling, if a phone is stolen, the attacker can use the passkey unless you enforce a PIN on the device.
  • Backup & recovery, platforms like iCloud Keychain sync passkeys across devices, but you should provide a fallback (e.g., email link) for users without a second device.

In one of my recent projects a client asked whether we could skip OTP entirely. I told them passkeys are great for the first factor, but a second factor (like a push notification) still adds defense in depth for high-value accounts.

Testing your implementation

I once spent an entire afternoon chasing a bug that only appeared on Safari because Apple’s WebAuthn implementation still requires a user gesture before calling navigator.credentials.create. The fix was to move the button click handler into a separate function and call it directly from the click event, not from a promise chain.

A quick checklist before you push to production:

  • ✅ Test on Chrome, Edge, Safari, and Firefox.
  • ✅ Verify registration works on hardware keys, Android, and iOS.
  • ✅ Check that the rpId matches your live domain.
  • ✅ Confirm the server validates attestation signatures.
  • ✅ Ensure fallback flow (password or magic link) works.

FAQ

How do I migrate existing password users to passkeys?

Start by offering passkeys as an optional login method. When a user logs in with a password, prompt them to "Add a passkey" after successful authentication. Store the new public key alongside the existing password hash; you can later deprecate passwords once most users have a passkey.

Can I use passkeys on a custom domain without HTTPS?

No. WebAuthn requires a secure context (HTTPS) except for localhost during development. Browsers will block the API on plain HTTP, so you must serve your site over TLS.

What if a user loses their authenticator?

Provide a recovery flow: either a backup passkey stored on another device, a one-time email link, or a recovery code generated at registration. The key is to let the user revoke the lost credential on the server and enroll a new one.

Wrapping up

If you’re still using passwords as your primary guard, you’re leaving the door open for credential stuffing and phishing. Swapping to passkeys with WebAuthn raises the bar dramatically with almost no friction for the user once the first credential is enrolled. The implementation details—especially around rpId, user verification, and binary encoding—are where most bugs hide, so test early and iterate. Once you have a solid passkey flow, layer in a secondary factor for the truly sensitive actions and you’ll have a modern, phishing-resistant authentication system.