Passkeys: how the replacement for the password works

Leonardo Gurgitano6 minLeer en español

A single object photographed in black and white against a dark background.
A single object photographed in black and white against a dark background.

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

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