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