loke.dev
Header image for 4 Hard-Won Lessons From Implementing Passkeys in a Legacy Auth Flow

4 Hard-Won Lessons From Implementing Passkeys in a Legacy Auth Flow

Replacing passwords isn't just about calling a browser API—it's about rethinking your entire account recovery strategy for a world without shared secrets.

· 5 min read

4 Hard-Won Lessons From Implementing Passkeys in a Legacy Auth Flow

Everyone keeps telling you that passkeys are the "password killer," but that’s a lie—or at least, it’s a very premature funeral. In reality, passkeys are a messy extension of your existing identity provider that forces you to confront every lazy shortcut you took in your auth logic back in 2014. If you drop a "Sign in with Passkey" button onto a legacy stack without rethinking your recovery flow, you aren't making things more secure; you're just building a very high-tech way for your users to get locked out of their accounts.

After migrating a few thousand users from "Password + SMS" to a WebAuthn-based flow, here are the things that actually broke, the things that worked, and the code I wish I’d had at the start.

1. The "Conditional UI" is the only way people will actually use it

We originally built a giant, shiny "Register a Passkey" button in the user settings. You know who clicked it? Two people. Me and the QA lead.

Most users don't know what a passkey is, and they don't care. The breakthrough for us was Conditional UI (also known as "passkey autofill"). This allows the browser to suggest a passkey right inside the existing password field.

To make this work, you have to call navigator.credentials.get as soon as the page loads, but with a special mediation: 'conditional' flag.

// This needs to run on page load, NOT on a button click
async function setupConditionalUI() {
  if (window.PublicKeyCredential && 
      PublicKeyCredential.isConditionalMediationAvailable) {
    
    const isAvailable = await PublicKeyCredential.isConditionalMediationAvailable();
    
    if (isAvailable) {
      try {
        const assertion = await navigator.credentials.get({
          mediation: 'conditional', // The magic sauce
          publicKey: {
            challenge: Uint8Array.from(SERVER_CHALLENGE, c => c.charCodeAt(0)),
            allowCredentials: [], // Let the browser find matches
            userVerification: 'preferred',
          }
        });
        // Send assertion to your backend for verification
        handleLogin(assertion);
      } catch (err) {
        console.error("Condition UI failed or was ignored", err);
      }
    }
  }
}

The Gotcha: Your <input> field for the username *must* have autocomplete="username webauthn". Without that attribute, the browser just sits there doing nothing, and you'll spend three hours debugging your JavaScript when the problem was actually HTML.

2. Recovery is a nightmare when there's no "Shared Secret"

In a legacy flow, if a user forgets their password, you send an email, they click a link, and they set a new "shared secret."

With passkeys, there is no shared secret. The private key lives on their MacBook or their iPhone. If they drop that iPhone in a lake and they haven't synced their iCloud Keychain, that key is gone forever.

We had to make a hard architectural decision: Passkeys are an "and," not an "instead of," for the first six months.

We kept the password flow alive but "demoted" it. If a user logs in via a passkey, we give them full access. If they log in via a recovery code or email link because they lost their device, we treat the session as "low-trust" and require a re-authentication or a 24-hour wait period before they can change sensitive settings like their billing address.

3. Storing Public Keys: The "Binary Blob" Headache

If you're used to storing a bcrypt string in a VARCHAR(255), WebAuthn is going to annoy you. You’re dealing with ArrayBuffers, CBOR encoding, and COSE key formats.

When a user registers, the browser sends you a "Credential Public Key." This isn't a simple string; it's a binary structure. On the backend (we were using Node.js), you can't just JSON-stringify the response and shove it in Postgres.

Here is a simplified look at what the registration verification looks like using the @simplewebauthn/server library (do not try to write your own CBOR parser, save your sanity):

import { verifyRegistrationResponse } from '@simplewebauthn/server';

const verification = await verifyRegistrationResponse({
  response: clientResponse, // What the frontend sent
  expectedChallenge: currentSession.challenge,
  expectedOrigin: 'https://myapp.com',
  expectedRPID: 'myapp.com',
});

if (verification.verified) {
  const { credentialID, credentialPublicKey, counter } = verification.registrationInfo;
  
  // Save these as BYTEA or BLOB in your DB
  await db.userCredentials.create({
    data: {
      userId: user.id,
      credId: Buffer.from(credentialID),
      publicKey: Buffer.from(credentialPublicKey),
      counter: counter,
    }
  });
}

Why the `counter`? It's a security feature. Every time a passkey is used, the device increments a counter. If the counter you receive is lower than or equal to the one in your database, it means someone cloned the authenticator. It's a niche edge case for hardware keys, but you have to track it anyway.

4. The "Attestation" rabbit hole is a trap

When we started, we spent weeks worrying about "Attestation Statements." This is the data that tells you *exactly* what kind of device the user is using (e.g., "This is a YubiKey 5C" or "This is a Titan Security Key").

Unless you are building a banking app or a government portal, set your `attestation` preference to `none`.

Why? Because if you demand "direct" attestation, the browser will pop up a scary privacy warning telling the user that the site wants to see their device's serial number. It's a huge conversion killer. Most of the time, you don't actually care if they're using a FaceID sensor or a hardware dongle; you just care that they have the private key.

Closing thoughts

Passkeys aren't just a drop-in feature. They're a fundamental shift in how you think about "identity." You're moving from a world where you *hold* the secret to a world where you *verify* a signature.

Start small. Implement it as an optional 2nd factor first. Get your head around the binary data handling and the "Conditional UI." Once you see a user log in with a single touch of a fingerprint scanner, you'll realize it's worth the migration pain—but don't delete that password reset logic just yet.