Passkeys are the fingerprint or face unlock your users already use on their phone, applied to signing in to your app. Nothing to remember, nothing to reset, and nothing in your database worth stealing.
We added them to Canner earlier this year. The core turned out to be about a day's work. Everything around the core took considerably longer, and that part is what this post is mostly about.
What you store
The pleasant surprise is how little. For each passkey you keep a credential ID, a public key, a signature counter, and the transports it supports. None of it is secret — if someone dumps the table they get nothing usable.
Two columns worth adding even if you ignore them at first: backed_up and device_type, which tell you whether a passkey syncs to a cloud keychain. That's exactly what you want to know later, when you're deciding how hard to nudge someone into enrolling a second one.
Registering a passkey
The server issues a random challenge, the browser signs it. Store the challenge server-side and bind it to the account that asked for it — otherwise a challenge issued for one account can be completed for another.
const options = await generateRegistrationOptions({
rpName: 'Your App',
rpID: 'yourapp.com',
userID: Buffer.from(account.id, 'utf8'),
userName: account.email,
excludeCredentials: existing, // don't enroll the same device twice
authenticatorSelection: {
residentKey: 'preferred', // enables usernameless sign-in
userVerification: 'preferred',
},
});Verifying the response takes three things: the stored challenge, your origin, and your RP ID. Set the last two from configuration, never from the request's own headers — those are the attacker's input.
rpIDis the one decision worth slowing down for. It's the domain your passkeys bind to, and it's permanent: set it to app.yourapp.com and they work on that subdomain only; set it to yourapp.comand they work everywhere under it. Changing it later doesn't migrate anything — every enrolled passkey is simply orphaned.
Signing in
This is the part worth demoing. Because you asked for a discoverable credential at registration, sign-in needs no identifier at all — send an empty allowCredentials and the browser offers whatever it holds for your domain.
const options = await generateAuthenticationOptions({
rpID: 'yourapp.com',
allowCredentials: [], // the browser already knows
});The user types nothing. A side effect: since you never ask who's signing in, your login form can't be used to test whether an address has an account. Username enumeration stops being a thing you have to think about.
Four things that surprised us
Everything above is well documented. What follows is where the time actually went.
Your sessions probably aren't revocable
Passkeys improve the login moment and change nothing after it. If you issue a stateless JWT, “sign out everywhere” is a button you can't honestly build.
That matters more without passwords, because the usual response to a compromise — change your password — no longer exists. Session revocation is the remedy. We put a session ID in the token and check a Redis tombstone on the hot path, with the full list in Postgres for the UI.
We got the fallback path backwards
Our first rule: if you have a passkey but sign in with a password or a social provider, let you in and email a security alert. It sounds careful. It's really a machine for teaching people to ignore security alerts, because it fires most often when someone has done something completely reasonable.
A passkey should be a gate, not a tripwire. The password now produces a short-lived pending token rather than a session, the passkey is requested next, and only skipping it sends the alert. Same pieces, opposite default — and now the alert means something.
Recovery has to replace, not add
Our first recovery flow revoked every session and sent the user to enroll a new passkey. Which achieves nothing if the attacker has the password — they just sign back in.
Recovery needs to revoke sessions, lock the password, unlink social accounts, remove the old passkeys, and enroll one fresh one. Also: mail scanners prefetch links, so if the recovery link acts on GET, corporate mail filters will consume it before the user ever clicks. Render a page; act on POST.
The small stuff
Don't ask users to name their passkey — derive it from the user agent (“Chrome on Windows”) and let them rename it later. Feature-detect explicitly, or an unsupported browser gives you a button that silently does nothing. Let people enroll two, because that's the cheapest way to keep them out of your recovery flow. And treat a dismissed biometric prompt as a cancellation, not an error.
What about social sign-on?
It's faster to add, and for plenty of apps it's a reasonable call. The tradeoff is a dependency: your users' access runs through an account you don't control, and the provider sees every sign-in.
The protocol isn't the issue — OAuth and OIDC are open specs with good open-source servers. But what makes social sign-on work is that users already have accounts with a handful of companies, and no amount of openness in the spec changes that. Standing up your own compliant OIDC server doesn't help, because nobody has an account there.
Passkeys sidestep it by not having a third party at all. The credential is generated on the user's device and belongs to them. They're also phishing-resistant in a way that doesn't rely on anyone being careful: the browser refuses to use a credential on any origin except the one it was created for, so a perfect replica of your login page on a lookalike domain still can't get a signature.
If you'd rather not build it
The two flows are a day. Session revocation, the fallback path, recovery, the emails, the management UI, and tests for all of it are a couple of weeks — and none of it is work that makes your product different from anyone else's.
That's why we packaged ours. The reason you need a users table is to store credentials; if something else stores them and hands you a signed assertion, you don't need the table. Canner Auth holds the user record and the public key, and your backend verifies a short-lived JWT against a public JWKS:
const { payload } = await jwtVerify(token, JWKS, {
issuer: process.env.CANNER_AUTH_ISSUER,
audience: process.env.CANNER_AUTH_APP_ID,
});That's the whole server-side integration. It fits headless apps, AI-built projects where auth is the weakest part of the output, and anything where auth is a prerequisite rather than the point. Declare the origins your app is served from and it works wherever it's hosted — with us or not.
Build it yourself if authentication is close to your product or you have requirements about where credentials live. It's a solved problem with good libraries. Just budget for the second half.
Canner Auth is on every plan, Starter included.