Passkeys, twice
I wanted FeedFilters to let people authenticate without trusting me with a password. Storing passwords is a category of responsibility I’d rather avoid — the breach risk, the ongoing decisions about hashing parameters and rate limits and lockouts. The usual escape hatch is to lean on a third party like Google or Apple for sign-in, but that brings its own baggage: a privacy story I don’t want, an external dependency I don’t want, and account-linking edge cases I don’t want. None of those felt like the right trade for a small indie service.
Passkeys are the modern alternative. The browser handles creation and storage on the user’s behalf, and modern devices sync them across the user’s other hardware via iCloud Keychain or 1Password or whatever they’re using. The user gets strong, phishing-resistant auth without having to remember anything; I get to not store passwords. That trade seemed right.
So passkeys are the default signup method on FeedFilters. There’s a password path too — an opt-in choice on a separate page — but the front door is a passkey. I made one design decision that I expect some people to push back on: when you add a passkey to your account, the default is for your password to be removed. The reasoning is that a passkey is a step up in security, and leaving a password sitting around as a secondary method undoes a lot of what the passkey gave you. If you’ve decided you want the stronger thing, I think it’s right to also retire the weaker thing, and that’s the path the UI recommends. The recovery story for losing a passkey is the same one you’d use for a forgotten password: a reset email. That’s the underlying reason FeedFilters verifies email addresses at signup — not for marketing reach, but so that the email channel is trustworthy as a recovery path.
That said, this started out as a hard rule rather than a default. Passkey-add unconditionally cleared the password, with a warning. I softened it later to a checkbox, on by default, that you can uncheck if you’d rather keep both factors. The cliff turned out to be harsher than necessary for users curious about passkeys but not yet ready to commit; the new default still nudges toward the stronger story, it just doesn’t force it.
The implementation didn’t go entirely smoothly.
The first thing that bit me was a WebAuthn detail I hadn’t expected
to think about. The go-webauthn library validates a “backup
eligibility” flag on every assertion: the stored credential’s BE
flag has to match the one in the assertion. I’d shipped without
persisting that flag, so it defaulted to zero, while real synced
platform passkeys come back with BE set. Every login attempt from a
synced passkey failed with “Backup Eligible flag inconsistency
detected.” The fix was a small migration and a few lines of code
— capture backup_eligible and backup_state at registration,
refresh backup_state on each successful assertion — but
finding it took some time with the spec. WebAuthn is a careful
specification, which is right for what it’s protecting, but
“careful” sometimes means “you have to handle subtleties you
wouldn’t have thought of.”
The first UX iteration came the same evening. The day-two build had passkeys and passwords sitting side by side on the signup form — effectively equal options — and treating them that way undersold what passkeys are for. So I made passkey the default path: the signup page leads with a single passkey button, and there’s a quieter link below for the people who’d rather use a password, routed through a separate password signup page. The path of least resistance should be the better path, and I wanted that to be the case here.
The second iteration came a couple of weeks later, and was a fix
for something I hadn’t realized I’d broken. The login page was
firing a modal navigator.credentials.get on page load for any
browser that supported PublicKeyCredential. The intent was
friendly: if you have a passkey for FeedFilters, just pick it from
the system prompt and you’re in. The reality was hostile: on every
browser that didn’t have a passkey for the site, the WebAuthn
modal would intercept whatever the user tried to do next, including
their click into the password field. The modal would dismiss, the
click would have been absorbed, and the user wouldn’t notice
anything had gone wrong other than the login page seeming weirdly
unresponsive.
The fix was to switch from auto-invoke to conditional UI, which is
exactly what the API was designed for. With mediation: 'conditional'
the browser doesn’t pop a modal at all; it surfaces available
passkeys inline as autofill suggestions in the email field,
alongside any saved emails. Users with a passkey see it offered in
the natural place. Users without one are completely undisturbed.
This is what the spec recommends, and I should have used it from
the start.
Passkeys are the right default for a service like this, but the path to “passkeys are pleasant” is paved with details that aren’t obvious until you trip over them. Two iterations in, I think the login and signup experiences are finally where I want them. I’ll be curious to see what the third one needs.