Passkeys and WebAuthn explained

By

Understand passkeys and WebAuthn, then build registration and login with server challenges, stored public keys, SimpleWebAuthn, recovery, and tests.

~~~

Passkeys let a user sign in without a password. Instead of a secret you type, the login uses a key pair. Your server stores the public key. The private key never leaves the authenticator.

The browser talks to that authenticator through WebAuthn, the API behind passkeys. The authenticator can be the device itself, a password manager, a phone, or a hardware security key.

Here is the difference in one picture:

password login -> user sends a shared secret
passkey login  -> authenticator signs a fresh challenge

With a password, something secret travels to the server on every login. With a passkey, nothing secret is sent. The authenticator signs a random challenge, and the server checks the signature with the public key it already has.

If you want the whole picture of authentication on the web, sessions, passwords, OAuth, recovery, my free Web Authentication course covers passkeys as one chapter of that.

Passkeys, WebAuthn, and authenticators

Three words come up all the time, and they are easy to mix up.

WebAuthn is the browser API and the protocol. It is what navigator.credentials.create() and navigator.credentials.get() implement.

A passkey is a WebAuthn credential that is discoverable. The browser can find it and offer it to the user without being told which credential to use first.

An authenticator is whatever holds the private key and does the signing. Before it signs, it usually asks the user for something: a fingerprint, a face, the device PIN, a touch on a security key.

Your site never sees the fingerprint. The device checks it locally, and only then unlocks the private key.

That gesture is also why passkeys don’t work for unattended logins. An AI agent has no finger to put on the sensor. I wrote about how agents can log in anyway in How AI agents can log in without seeing your passwords.

Why passkeys resist phishing

Every day, people type their passwords into fake sites that look like the real one.

A WebAuthn credential is tied to a relying party ID, or RP ID. That is your domain. The browser and the authenticator refuse to use the credential anywhere else.

So a credential created for shop.example.com cannot be used on shop-example.com. The fake site does not get a usable signature, no matter how convincing the page looks.

The browser does its part, but your server still has to check the origin and the RP ID in every response.

This is the part that hashing passwords correctly can’t fix. A hashed password is still a shared secret, and a user can still hand it to the wrong page. Passkeys take that step out of the login entirely.

Synced and device-bound passkeys

Not all passkeys behave the same way.

Some sync. Apple Passwords, Google Password Manager, and third-party password managers copy the passkey to the user’s other devices. Lose the phone, and the passkey is still on the laptop.

Others live on one authenticator and nowhere else. A hardware security key usually works this way. Lose the key and the credential is gone with it.

Build for both. Let users register more than one passkey, and give them a recovery path that does not assume the passkey synced somewhere.

The two ceremonies

WebAuthn calls its flows ceremonies. There are two.

Registration creates a new credential and sends its public part to your server.

Authentication proves that the user still controls that credential.

Both start the same way: the server generates a random challenge and sends it to the browser.

Registration

sequenceDiagram
  participant S as Server
  participant B as Browser
  participant A as Authenticator
  S->>B: challenge and options
  B->>A: navigator.credentials.create()
  A->>A: create key pair
  A->>B: new credential
  B->>S: credential response
  S->>S: verify and store public key

Authentication

sequenceDiagram
  participant S as Server
  participant B as Browser
  participant A as Authenticator
  S->>B: challenge and options
  B->>A: navigator.credentials.get()
  A->>A: sign challenge
  A->>B: assertion
  B->>S: assertion
  S->>S: verify signature with stored public key
  S->>B: normal application session

The challenge is what makes replay impossible. Every response is a signature over a fresh random value, so a captured response is worthless the next time around.

What the server must verify

The library does most of the work here, but you should know what it checks. During both ceremonies:

  • the response contains the challenge you issued
  • the challenge is unexpired and has not been used
  • the response type matches the ceremony
  • the origin is one you expect
  • the RP ID matches your application
  • user presence is set
  • user verification is set when you require it
  • the cryptographic signature or attestation is valid

During authentication, also check that the credential belongs to the user who started this login, not just to some user.

Do not write this verification yourself. There is CBOR parsing, COSE key handling, and signature verification involved, and one wrong flag check breaks the whole guarantee. Use a maintained WebAuthn library or let an authentication provider handle it.

Use HTTPS

WebAuthn only works in secure contexts, so production means HTTPS.

Browsers make an exception for localhost, so you can develop and test without a public domain.

Pick your production RP ID carefully. Every credential is bound to it for life. If you later move from one domain to an unrelated one, existing passkeys stop working there, and you need a migration plan, not just a DNS change.

Install SimpleWebAuthn

We’ll use SimpleWebAuthn. It handles the binary protocol and leaves us with readable server code where the checks are still visible.

Install the server and browser packages:

npm install @simplewebauthn/server @simplewebauthn/browser

The examples use a fictional bookshop as the app:

const rpName = 'Books'
const rpID = 'books.example.com'
const expectedOrigin = 'https://books.example.com'

For local development, use the localhost values the library documents.

Generate registration options

Adding a passkey changes how someone gets into their account, so only a logged-in user should be able to do it.

The endpoint loads the current user and the passkeys they already have:

import { generateRegistrationOptions } from '@simplewebauthn/server'

app.get('/api/passkeys/register/options', requireUser, async (
  request,
  response
) => {
  const passkeys = await getPasskeysForUser(request.user.id)

  const options = await generateRegistrationOptions({
    rpName,
    rpID,
    userName: request.user.email,
    attestationType: 'none',
    excludeCredentials: passkeys.map(passkey => ({
      id: passkey.credentialId,
      transports: passkey.transports
    })),
    authenticatorSelection: {
      residentKey: 'preferred',
      userVerification: 'required'
    }
  })

  await savePasskeyChallenge({
    userId: request.user.id,
    kind: 'registration',
    challenge: options.challenge,
    expiresAt: new Date(Date.now() + 5 * 60_000)
  })

  response.json(options)
})

excludeCredentials lists the credentials the user already registered, so the authenticator does not create a duplicate.

attestationType: 'none' tells the authenticator we don’t need proof of which hardware it is. Most apps don’t, and asking for it collects identifying information for no reason.

The challenge goes into the database, tied to the user and to the registration ceremony, with a five-minute expiry. We’ll need it back in the verify step.

Start registration in the browser

On the client, startRegistration() takes care of the base64url conversions and calls navigator.credentials.create():

import { startRegistration } from '@simplewebauthn/browser'

async function registerPasskey() {
  const optionsResponse = await fetch(
    '/api/passkeys/register/options'
  )

  if (!optionsResponse.ok) {
    throw new Error('Could not start passkey registration')
  }

  const optionsJSON = await optionsResponse.json()
  const registration = await startRegistration({ optionsJSON })

  const verifyResponse = await fetch(
    '/api/passkeys/register/verify',
    {
      method: 'POST',
      headers: {
        'content-type': 'application/json'
      },
      body: JSON.stringify(registration)
    }
  )

  if (!verifyResponse.ok) {
    throw new Error('Passkey registration failed')
  }
}

The browser shows its own UI for the authenticator. You don’t control that dialog, so make sure the page around it says what is happening and to which account.

Verify registration on the server

The verify endpoint reads the challenge back, checks the response against it, and stores the new credential:

import { verifyRegistrationResponse } from '@simplewebauthn/server'

app.post('/api/passkeys/register/verify', requireUser, async (
  request,
  response
) => {
  const challenge = await consumePasskeyChallenge({
    userId: request.user.id,
    kind: 'registration'
  })

  if (!challenge || challenge.expiresAt < new Date()) {
    return response.status(400).json({
      error: 'Registration challenge expired'
    })
  }

  const verification = await verifyRegistrationResponse({
    response: request.body,
    expectedChallenge: challenge.challenge,
    expectedOrigin,
    expectedRPID: rpID,
    requireUserVerification: true
  })

  if (!verification.verified || !verification.registrationInfo) {
    return response.status(400).json({
      error: 'Registration could not be verified'
    })
  }

  const { credential, credentialDeviceType, credentialBackedUp } =
    verification.registrationInfo

  await savePasskey({
    userId: request.user.id,
    credentialId: credential.id,
    publicKey: credential.publicKey,
    counter: credential.counter,
    transports: credential.transports,
    deviceType: credentialDeviceType,
    backedUp: credentialBackedUp
  })

  response.json({ verified: true })
})

consumePasskeyChallenge() has to return the challenge and delete it in one step. A transaction or an atomic delete-and-return does that. If two requests can both read the same challenge, it is no longer single-use.

We store the credential ID, the public key, the counter, the transports, the link to the user, and the backup flags. The library’s types tell you the exact binary shapes.

We never store the private key, because it never reached the server in the first place.

Generate authentication options

Now the login. This version asks for the email first, then offers the passkeys for that account:

import { generateAuthenticationOptions } from '@simplewebauthn/server'

app.post('/api/passkeys/login/options', async (request, response) => {
  const user = await findUserByEmail(request.body.email)

  if (!user) {
    return response.status(400).json({
      error: 'Could not start sign in'
    })
  }

  const passkeys = await getPasskeysForUser(user.id)

  const options = await generateAuthenticationOptions({
    rpID,
    allowCredentials: passkeys.map(passkey => ({
      id: passkey.credentialId,
      transports: passkey.transports
    })),
    userVerification: 'required'
  })

  const flowId = crypto.randomUUID()

  await savePasskeyChallenge({
    flowId,
    userId: user.id,
    kind: 'authentication',
    challenge: options.challenge,
    expiresAt: new Date(Date.now() + 5 * 60_000)
  })

  response.json({ flowId, options })
})

Notice the error when the user is not found. It says “Could not start sign in”, not “no such user”. That alone does not stop account enumeration, because the response timing and rate limits have to be consistent too. But it is the first step.

The flowId is there for a practical reason. If someone opens two login tabs, each tab gets its own challenge. Keying the challenge on the user alone would let the second tab overwrite the first.

Start authentication in the browser

startAuthentication() mirrors the registration call:

import { startAuthentication } from '@simplewebauthn/browser'

async function signInWithPasskey(email) {
  const optionsResponse = await fetch(
    '/api/passkeys/login/options',
    {
      method: 'POST',
      headers: {
        'content-type': 'application/json'
      },
      body: JSON.stringify({ email })
    }
  )

  const { flowId, options } = await optionsResponse.json()
  const authentication = await startAuthentication({
    optionsJSON: options
  })

  const verifyResponse = await fetch(
    '/api/passkeys/login/verify',
    {
      method: 'POST',
      headers: {
        'content-type': 'application/json'
      },
      body: JSON.stringify({
        flowId,
        authentication
      })
    }
  )

  if (!verifyResponse.ok) {
    throw new Error('Passkey sign in failed')
  }
}

When the verify request succeeds, the server creates a session the same way it would after a password login. Passkeys only answer the question “is this the user?”. Keeping them logged in is still the job of your session cookie.

Verify authentication

The verify endpoint loads the challenge by flowId, finds the exact credential, and checks the signature:

import { verifyAuthenticationResponse } from '@simplewebauthn/server'

app.post('/api/passkeys/login/verify', async (request, response) => {
  const challenge = await consumePasskeyChallenge({
    flowId: request.body.flowId,
    kind: 'authentication'
  })

  if (!challenge || challenge.expiresAt < new Date()) {
    return response.status(400).json({
      error: 'Authentication challenge expired'
    })
  }

  const passkey = await getPasskey({
    userId: challenge.userId,
    credentialId: request.body.authentication.id
  })

  if (!passkey) {
    return response.status(400).json({
      error: 'Authentication could not be verified'
    })
  }

  const verification = await verifyAuthenticationResponse({
    response: request.body.authentication,
    expectedChallenge: challenge.challenge,
    expectedOrigin,
    expectedRPID: rpID,
    credential: {
      id: passkey.credentialId,
      publicKey: passkey.publicKey,
      counter: passkey.counter,
      transports: passkey.transports
    },
    requireUserVerification: true
  })

  if (!verification.verified) {
    return response.status(400).json({
      error: 'Authentication could not be verified'
    })
  }

  await updatePasskeyCounter(
    passkey.id,
    verification.authenticationInfo.newCounter
  )

  await createSession(response, challenge.userId)
  response.json({ verified: true })
})

After a successful check we save the new counter.

Authenticators can increment this counter on each use. If a stored counter is higher than the one in a new response, the credential may have been cloned. But many authenticators always report zero, so a counter that stays flat is not proof of anything. Let the library do the protocol check, and decide separately how your app treats a suspicious counter.

Username-less login

Because passkeys are discoverable, the browser can list them before the user types anything.

For that flow, call generateAuthenticationOptions() without allowCredentials. The response that comes back carries a user handle and a credential ID, and your server uses those to look up the account.

You can also surface passkeys in the autofill dropdown of the email field. This is called conditional UI:

<input
  name="email"
  type="email"
  autocomplete="username webauthn"
>

It is a nicer login, but it means feature detection and a long-lived pending request in the background. Get the plain “Sign in with a passkey” button working first, then add this.

Let users manage credentials

Users need a page where they can see and remove their passkeys.

For each one, show:

  • a user-chosen name such as “MacBook”
  • when the credential was added
  • when it was last used
  • whether it appears backed up, when known
  • a remove action

Ask for a fresh authentication before adding or removing a passkey. A laptop left open in a café should not be enough to swap the login method on an account.

And allow more than one. A common setup is a synced passkey for daily use and a hardware key kept in a drawer.

Recovery is part of authentication

Passkey-only accounts do not need password resets, but they still need a recovery path.

Devices get lost. People leave the ecosystem their passkeys were synced to. Someone removes the last credential by mistake.

Decide how recovery works before launch. The options are the usual ones:

  • another registered passkey
  • a carefully protected recovery code
  • a verified support process for high-value accounts
  • another existing login factor during migration

Whatever you pick becomes the weakest door into the account, and attackers go through that door, not through WebAuthn. If recovery is a plain email link, an attacker with the mailbox, or with a stolen session, owns the account no matter how good the passkey is.

Common mistakes

Generating the challenge in the browser. The challenge exists so the server can check that a response was made for this login. If the client picks it, that check means nothing, so only the server should create challenges.

Reusing a challenge. Each challenge expires in minutes and can be consumed once, so make the consume step atomic.

Skipping the origin check. Compare against an exact list of expected HTTPS origins. Accepting any subdomain is a decision you should make on purpose, if at all.

Treating the display name as identity. Emails and display names change. Key everything on your internal user ID.

Storing one challenge per user. Two tabs will overwrite each other. That is what the flowId above is for.

Assuming every passkey syncs. Some don’t, and multiple credentials plus a recovery path cover that case.

Writing your own verification. Encoding, COSE keys, CBOR, flags and signatures are all places to get it subtly wrong, so use a library.

Forgetting rate limits. The options and verify endpoints need them. A generic error message does not stop someone from hammering the endpoint to enumerate accounts.

Test the complete flow

One green test for a happy-path registration is not enough. Write tests for:

  • expired challenge
  • reused challenge
  • wrong origin
  • wrong RP ID
  • unknown credential ID
  • missing user verification when required
  • two concurrent login tabs
  • duplicate credential registration
  • removed credential
  • recovery after losing the primary device

Browser developer tools include a virtual authenticator, which is what you want for automated tests. Then, before launch, try the real thing: a platform passkey on a phone and a physical security key.

How I would add passkeys

I would add them next to the login method that already exists, not instead of it.

The settings page would come first: register more than one credential, name each one, remove one, all behind a recent authentication. Recovery would be working before I ask anyone to rely on passkeys.

A maintained library handles the protocol. My own code stays on users, sessions, challenges, credential records, rate limits, and the product flows around them.

Making passkeys the only login on day one is something I would only do if I controlled the devices and the support process. The crypto can be perfect while recovery is still a mess, and the recovery mess is what users run into.

Want me to talk about your product? You can sponsor this site.

~~~

Related posts about network: