Passkeys: how the replacement for the password works

You open your bank’s app, touch the sensor, and you are in. You typed nothing. There was no email with a code, no password to remember, no second factor to copy before it expired.
That is a passkey, and you have probably used one already without naming it.
The interesting part for a programmer is not the convenience but what sits behind it: there stops being a secret in your database to steal. No passkey stored on your server can be used to get into any account, not even if somebody walks off with the entire table.
It is worth understanding why, because it changes what you store and what can go wrong.
What a passkey actually is
It is a pair of cryptographic keys. A private one, which never leaves the device, and a public one, which you give to the server.
And from that comes the central property: the server stores no secret. If somebody steals your entire database, they walk away with public keys, which are public by definition and get them into nothing.
Compare that with passwords: even with a good derivation algorithm, a stolen database forces everyone to change their password before somebody cracks the weakest ones. With passkeys there is nothing to crack.
What happens when registering
The server generates a challenge — random bytes — and describes what it wants:
// On the server
const options = {
challenge: randomBytes(32), // unique, single use
rp: { name: 'My site', id: 'mysite.com' },
user: {
id: userBinaryId, // do NOT use the email here
name: 'leo@example.com',
displayName: 'Leo',
},
pubKeyCredParams: [
{ type: 'public-key', alg: -7 }, // ES256
{ type: 'public-key', alg: -257 }, // RS256
],
authenticatorSelection: {
residentKey: 'required', // the key is stored on the device
userVerification: 'preferred', // fingerprint, face or PIN
},
};
Two details that get overlooked:
user.id has to be an internal, opaque identifier, not the email. It will be
stored on the user’s device forever. If you use the email and the person changes
it, the device keeps the old one. A random, stable identifier avoids the problem.
residentKey: 'required' is what makes this a real passkey. It causes the key
to be stored on the device along with the account details, which is why the user
can sign in without typing a username: the browser already knows who they are.
Without that option, the user has to identify themselves first.
In the browser:
const credential = await navigator.credentials.create({ publicKey: options });
At that point the operating system asks for the fingerprint or the face, generates the key pair, and returns the signed public key.
The server stores three things:
CREATE TABLE passkeys (
id bytea PRIMARY KEY, -- the credential identifier
user_id uuid NOT NULL REFERENCES users(id),
public_key bytea NOT NULL,
counter bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
last_used_at timestamptz,
description text -- "Leo's iPhone"
);
What happens when signing in
The server sends another challenge. The device signs it with the private key. The server verifies the signature with the public key it stored.
The password never travels because it does not exist. And because the
signature includes the domain that asked for it, a phishing page at
mysite.com.attacker.net cannot obtain a valid signature for mysite.com. The
browser will not give it one. That makes passkeys phishing-resistant by
construction, not by user education.
The counter field deserves a note: some devices increment a number on each use.
If a value arrives that is lower than or equal to the stored one, that is a sign
somebody cloned the credential. Many modern authenticators always return zero —
Apple’s, for example — so the check is: if the incoming value is greater than
zero, it has to be greater than the stored one.
Is it time yet?
Two numbers are enough to answer that.
The first is the one that convinces whoever holds the budget, and it has nothing to do with security: according to the FIDO Alliance, the successful sign-in rate with a passkey is around 93%, against 63% for traditional methods. Four in ten password attempts fail — forgotten, mistyped, locked out — and each one is somebody abandoning a purchase or calling support.
The second says you will not be first: the same source estimates around 5 billion passkeys in use, and three in four people already have at least one account with a passkey. The feature exists in every current browser and operating system.
That said, there are three things the announcements do not tell you.
The three problems nobody mentions
1. Recovery is still the weak link
If your only way in is a passkey on a phone and you lose the phone, you are locked out. The providers sync passkeys through their cloud — iCloud, Google’s manager — which solves the common case, but ties you to that ecosystem.
In practice this means you need a recovery method, and that method is usually an email link. Which makes the real security of the account the security of the mailbox. That is not an argument against: it is a reminder that the weakest link moved, it did not disappear.
The reasonable minimum: require at least two registered passkeys before allowing the password to be turned off.
2. A long coexistence with passwords
Although almost every large company is already rolling out passkeys, more than half still have a phishing-vulnerable method as their primary way in. The transition is not a flip of a switch.
Plan for both to coexist for a good while, with the passkey as the preferred path and the password as the fallback. And measure how many people use each, because that number is what will tell you when you can turn the old one off.
3. Borrowed and other people’s devices
A passkey lives on a device. Signing in from somebody else’s computer requires the cross-device flow: the machine shows a QR code, you scan it with your phone, and the signature travels over Bluetooth.
It works well, and it is an extra step people do not expect. If your product gets used often from shared machines, take that into account before removing the fallback.
How I would approach it
Do not implement it from scratch. The WebAuthn specification has many details
where you can go wrong silently: validating the origin, checking the credential
type, checking the user-presence flag. Use a library that verifies all of that —
SimpleWebAuthn in JavaScript is the reference, and there are equivalents in
every language.
If you already use an identity provider, it probably has this already: .NET 10 added passkeys to Identity, and managed authentication services ship it as an option to switch on.
The order that makes sense: first offer the passkey as a second factor, which breaks nothing. Then as the primary method with the password still available. And only when the numbers tell you almost nobody uses the password, consider removing it.
Comments
Sign in to comment and to like this article.
No comments yet. Be the first to write one.