Skip to content
Chapter 53Lesson 7

Passkeys and WebAuthn

Wire Better Auth's passkey plugin to add phishing-resistant WebAuthn sign-in, the credential that replaces passwords and TOTP with a single biometric tap.

Every credential you have shipped so far can be handed to the wrong site by a person who thinks they are on the right one. A password is a string the user types, and they will type it into acme-login.com as readily as acme.com. Adding TOTP closed the door on leaked and reused passwords, but it left this one open: a live phishing proxy can ask the user for their six-digit code and relay it to the real site inside the ninety seconds it stays valid. The weak point is not the password and not the code. It is that the human can be tricked into handing a working credential to a look-alike site, and no amount of “use a stronger password” closes that gap, because the credential was never the weak part. The user’s judgment was.

A passkey closes the gap by structure rather than by asking the user to be more careful. It is a private key that the browser will use to sign a challenge only when the request comes from the exact site the key was registered to. There is no string to type, so there is nothing to type into the wrong place. By the end of this lesson you will wire Better Auth’s passkey() plugin for both registration and sign-in, be able to explain to a skeptical teammate why the ceremony cannot be phished, and make the two product calls passkeys put in front of you: synced versus device-bound, and passkey-as-primary-sign-in versus passkey-as-second-factor.

Why a phishing proxy beats TOTP but not a passkey

Section titled “Why a phishing proxy beats TOTP but not a passkey”

It helps to walk the attack concretely, because the rest of the lesson depends on seeing exactly where it fails. The user gets an email, clicks a link, and lands on acme-login.com, a pixel-perfect copy of your sign-in page sitting on a look-alike domain. They type their email and password. The phishing site does not check them; it forwards them to the real acme.com and signs in as the user. The real site sees a valid password and, because you did your job in the last lesson, demands a second factor. So the phishing site shows its own “enter your code” screen. The user opens their authenticator app, reads the six digits, and types them in. The proxy relays those digits to acme.com before they expire. The real site is satisfied, hands back a session, and the attacker is now inside the account.

TOTP lost for one reason: the code is a string the user can be talked into relaying. It does not matter that the code is single-use or that it expires in ninety seconds. The attacker only needs it to survive one relay inside that window, and a live proxy clears that bar easily. The user did everything they were told, and still got phished.

Now run the identical attack against a passkey. The user is on acme-login.com again, and the look-alike page asks the browser to do a passkey sign-in for acme.com. Here the attack dead-ends. The browser checks the origin the request is actually coming from, sees acme-login.com, and refuses to even invoke the authenticator, because that passkey was registered to acme.com and only acme.com. The user is never shown a prompt to relay, because there is nothing to relay: the signature is never produced. The phish stops at the browser’s origin check, not at the user’s judgment. That property has a name: a passkey is phishing-resistant , and it is the one thing TOTP could not give you.

The diagram below puts the two attacks side by side. Flip between the tabs and notice that the boxes do not move; the only thing that changes is whether the credential makes it across the attacker’s hop.

The user You on the look-alike page
types the 6-digit code
Phishing site Attacker acme-login.com
relays the code
Real site Acme acme.com

The code is a string. It survives one relay through the attacker’s site and still works at the real one.

The contrast is the whole point. TOTP asks the user to be the origin check: to look at the address bar and decide the site is real before they read out their code. Passkeys move that check into the browser, where it cannot be socially engineered. A relying party never sees a phished signature, because the signature for the wrong origin is never made.

To wire any of this you need a small mental model: who the actors are, and what the two round-trips between them do. Get these straight once and the code reads as a thin wrapper over them.

There are three roles in WebAuthn , the W3C standard. (FIDO2 is the umbrella name for the same machinery.)

  • Relying party (RP): your SaaS server. It stores users’ public keys and issues the random challenges they sign. A config value called rpID scopes which domain it speaks for.
  • User agent: the browser. It is the only actor that enforces the origin check, the step that made the phish dead-end a moment ago. Everything about phishing-resistance lives here.
  • Authenticator : the thing that holds the private key and unlocks it with a local gesture, such as Face ID, Touch ID, Windows Hello, or a tap on a hardware key. There are two kinds, named once now so the rest of the lesson can use the words. A platform authenticator is built into the phone or laptop; a roaming (or cross-platform) authenticator is a separate device you plug in or tap, like a YubiKey.

Between these three roles, everything happens through two ceremonies. Hold them apart the way you held enrollment and challenge apart for TOTP in the last lesson: two flows that share machinery but are governed by different rules.

Registration mints a new credential. Under the hood the browser calls navigator.credentials.create(). The authenticator generates a fresh keypair, keeps the private key locked in the secure enclave , and hands the public key plus an attestation back to the RP, which stores a row in a passkey table.

Authentication proves you hold a credential you registered earlier. The browser calls navigator.credentials.get(). The RP issues a time-bound random challenge, the authenticator signs it with the private key, and the RP verifies the signature against the public key it stored at registration.

The asymmetry between those two ceremonies is what makes the whole scheme safe, and it is worth stating plainly because it is stronger than anything you have shipped so far. With public-key cryptography , the private key never leaves the enclave: not at registration, not at sign-in, never. The server only ever holds the public key. You have met a version of this property before: in earlier lessons the database stored a hash of the password and a hash of each token, never the secret itself. Passkeys take that idea one rung higher. There, the server hashed a secret it had briefly seen. Here, the server never had the secret at all. A full database snapshot, every passkey row leaked, authenticates nobody, because a public key verifies signatures and cannot produce them.

The diagram below is the map for the rest of the lesson. It names all three roles and draws both ceremonies as round-trips between them. Every sequence later in this lesson zooms in on one of these two pairs of arrows.

Registration create()
navigator.credentials.create()
public key + attestation
Relying party Your server stores public keys, issues challenges
User agent Browser enforces the origin check
Authenticator Secure enclave holds the private key — never leaves
navigator.credentials.get()
challenge
signature
Authentication get()

Three roles, two round-trips. Registration hands the RP a public key; authentication hands it a signature the RP checks against that key. The private key stays in the enclave through both.

One note on names: navigator.credentials.create and .get are the platform primitives the browser exposes, and the library calls them for you. You will never write them by hand. What you actually write is authClient.passkey.addPasskey for registration and authClient.signIn.passkey for sign-in, two method calls. The model above is the part worth holding in your head; the code is small.

Before moving on, sort these facts into the ceremony each belongs to. This is the same discipline you practiced when separating TOTP enrollment from the TOTP challenge: if you can place each item cleanly, you hold the model.

Sort each fact into the ceremony it happens in. Drag each item into the bucket it belongs to, then press Check.

Registration Minting a new credential — the create ceremony
Authentication Proving you hold one — the get ceremony
Generates a new keypair
Server stores a public key
Browser calls navigator.credentials.create
Produces an attestation
Signs a server-issued challenge
Server verifies a signature
Browser calls navigator.credentials.get
Looks up an existing passkey row by credential ID
Checks the signing counter
Needs a device you’ve already registered

The short video below reinforces the model visually. It is optional; the prose above is sufficient.

Wiring the plugin is short, because it is the pattern you already know from the magic-link and TOTP plugins: register it on the server, register its partner on the client, regenerate the schema. The only thing here that will trip you up is which package each import comes from.

A plugin is two registrations that must match. The server half is passkey() in the plugins array of your auth instance in lib/auth.ts. The client half is passkeyClient() in the matching array in lib/auth-client.ts. This is the same rule every plugin in this chapter follows: forget the client half and authClient.passkey.* and authClient.signIn.passkey simply do not exist on the client, because those methods are contributed by the client plugin. What is different this time is the import paths, because the passkey plugin ships from its own package, not from Better Auth core.

With the imports right, the server registration carries a handful of config options. Most are labels; one is the single most common way to get passkeys subtly wrong. Step through them.

import { betterAuth } from 'better-auth';
import { passkey } from '@better-auth/passkey';
export const auth = betterAuth({
plugins: [
passkey({
rpName: 'Acme',
rpID: 'app.example.com',
origin: 'https://app.example.com',
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
}),
],
});

The human-readable label shown in the OS or browser passkey prompt, as in “Save a passkey for Acme?”. It is cosmetic and does not affect security.

import { betterAuth } from 'better-auth';
import { passkey } from '@better-auth/passkey';
export const auth = betterAuth({
plugins: [
passkey({
rpName: 'Acme',
rpID: 'app.example.com',
origin: 'https://app.example.com',
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
}),
],
});

The domain the credential is bound to. It must be the registrable domain the app actually runs on. This is the origin-binding from earlier, frozen into a config string, and the single most common way to break passkeys.

import { betterAuth } from 'better-auth';
import { passkey } from '@better-auth/passkey';
export const auth = betterAuth({
plugins: [
passkey({
rpName: 'Acme',
rpID: 'app.example.com',
origin: 'https://app.example.com',
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
}),
],
});

The full origin, scheme plus host, that the server expects requests from. Pair it with rpID: rpID is the host, and origin is the whole thing including https://.

import { betterAuth } from 'better-auth';
import { passkey } from '@better-auth/passkey';
export const auth = betterAuth({
plugins: [
passkey({
rpName: 'Acme',
rpID: 'app.example.com',
origin: 'https://app.example.com',
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
}),
],
});

residentKey: 'preferred' asks the authenticator to store a discoverable credential, which is what enables the no-typing sign-in autofill later. userVerification: 'preferred' requires the local gesture (Face ID or PIN) wherever the device supports it.

1 / 1

The rpID value is worth slowing down on, because misconfiguring it produces a failure that is hard to debug: registration succeeds and sign-in silently fails. The rule is that rpID must be the registrable domain your app runs on. Suppose your app lives at app.example.com and you set rpID: 'example.com'. The browser will happily register a passkey, and then refuse every assertion , because the key was scoped to example.com but the request is coming from app.example.com. A passkey registered under app.example.com works at neither marketing.example.com nor bare example.com. This is the same origin-binding that stopped the phishing proxy, except now you are the one on the wrong origin. When a passkey works in registration testing and dies at sign-in, rpID is the first thing to check.

Like every Better Auth plugin, passkey() needs database tables, and you generate them the same way as in the previous chapter: npx @better-auth/cli generate reads your config and writes the migration. The plugin adds one new table, passkey, with one row per credential. A user normally has several, such as a phone, a laptop, and maybe a hardware key, so model and build everything here as one-user-to-many-passkeys from the start.

A few columns carry teaching weight; the rest are bookkeeping the library reads for you.

passkey one row per credential
id text primary key
userId text FK → user.id — the one-user-to-many side
credentialID text public identifier; how a sign-in looks the row up
publicKey text the secret (public) the stored public half — a leak of this column authenticates nobody
counter integer monotonic signing counter (inert for synced keys)
deviceType 'singleDevice' | 'multiDevice' decision pinned to one device, or synced across many
backedUp boolean decision the sync signal — true when a cloud keychain holds it
transports text how the authenticator is reached — USB, NFC, internal
name text user-facing label — "iPhone", "Work laptop"
aaguid text device-model identifier
createdAt timestamp when the credential was registered
One row per credential. publicKey is the only secret-shaped thing here, and it's public. deviceType and backedUp are the two columns you'll make a product decision on next.

Two of those columns deserve a sentence each. counter is a monotonically increasing number the authenticator bumps on every signature, and the library checks that it only ever grows, which would catch a cloned single-device authenticator signing in parallel. Treat it as a defense the library performs, not as the thing protecting you, because synced passkeys (the common case, covered in the next section) report counter: 0 and never increment it, so for them the check is effectively inert. It is real protection only for single-device authenticators. The other two columns, deviceType and backedUp, are the signal for whether a credential is synced across the user’s devices or pinned to one piece of hardware. Note them here; you will decide what to do with them two sections from now.

Now for the first ceremony, end to end. The default, and the one you build first, is adding a passkey to an account the user is already signed in to: a credential they attach to an existing account from a settings page. That default has a name in the config. registration.requireSession is true out of the box, which is why the flow below starts from a live session.

Here is the round-trip. Scrub through it; each step maps to one hop in the roles diagram from earlier.

Settings page The user /settings/security/passkeys
click
User agent Browser runs addPasskey()
Authenticator Secure enclave private key never leaves
Relying party Your server stores public keys

On /settings/security/passkeys, with a live session (which is why requireSession defaults to true), the user clicks “Add a passkey.”

Settings page The user /settings/security/passkeys
User agent Browser runs addPasskey()
gesture
Authenticator Secure enclave private key never leaves
Relying party Your server stores public keys

authClient.passkey.addPasskey({ name: 'iPhone' }) runs. The browser prompts for the local gesture: Face ID, Touch ID, Windows Hello, or a hardware-key tap.

Settings page The user /settings/security/passkeys
User agent Browser runs addPasskey()
Authenticator Secure enclave generates the keypair
public key + attestation
Relying party Your server stores public keys

The authenticator generates a keypair, stores the private key in the enclave (or in iCloud Keychain / Google Password Manager for a synced passkey), and returns the public key + attestation. The private key never leaves.

Settings page The user /settings/security/passkeys
User agent Browser runs addPasskey()
Authenticator Secure enclave private key never leaves
public key + attestation
Relying party Your server inserts the passkey row

The library verifies the attestation, checks the origin against rpID, and inserts the passkey row with userId, credentialID, publicKey, and name.

Settings page The user lists the new passkey
User agent Browser runs addPasskey()
Authenticator Secure enclave private key never leaves
Relying party Your server inserts the passkey row

The settings UI lists the new credential by its name, with its device type and a remove button.

The call itself is small. This is the moment to flag a deliberate departure from how you have built every form so far in this chapter: passkey registration is not a Server Action. There is no FormData, no Zod boundary, and no Result discriminant to wrap, because the browser client owns the entire WebAuthn round-trip. addPasskey talks directly to the authenticator and to the Better Auth endpoint. So do not reach for the action skeleton here. Spend your attention on the model; the code is this:

'use client';
import { authClient } from '@/lib/auth-client';
export function AddPasskeyButton() {
const handleAdd = async () => {
const { error } = await authClient.passkey.addPasskey({ name: 'iPhone' });
if (error) {
// Surface "couldn't add a passkey — try again"; keep other methods visible.
return;
}
// Refresh the credential list.
};
return <button onClick={handleAdd}>Add a passkey</button>;
}

In a real settings page the name would come from a small input, such as “iPhone” or “Work laptop”, so the user can tell their credentials apart later.

That covers the default. The variation worth naming, though you will not build it in this lesson, is passkey-first onboarding: a brand-new user creating their first passkey with no prior session at all, the “no account, no password, just a passkey” sign-up. That flips registration.requireSession to false and adds a server resolveUser callback that tells Better Auth which user (or new user) the fresh credential belongs to. It is a senior-level reach, not the default. Most products add passkeys from settings and let passkey sign-in take over once the credential exists; passkey-first sign-up is an explicit opt-in you turn on when “zero passwords, ever” is a real product goal.

The rest of the management surface is three one-line calls, and they exist because users accumulate credentials and let them go stale:

  • authClient.passkey.listUserPasskeys() renders the list.
  • authClient.passkey.updatePasskey({ id, name }) renames one.
  • authClient.passkey.deletePasskey({ id }) removes one.

Build this list assuming many passkeys per user, and surface a device label (and a “last used” hint if you track it) so people can recognize and prune the credential from the laptop they sold a year ago. A management UI that assumes one passkey per user is a management UI you will rewrite.

The second ceremony is where the work you did pays off, and where the 2026 user experience is genuinely new, so it gets the most attention. There are two entry points, and both call authClient.signIn.passkey.

The explicit one is a button: a “Sign in with a passkey” control that calls authClient.signIn.passkey() when clicked. It is simple, and the right fallback.

The one worth building is conditional-UI autofill, the reason passkeys feel effortless in 2026. Instead of a separate button, the user’s available passkeys appear inside the email field’s autofill dropdown, the same place the browser offers saved usernames. The user focuses the field, sees “Sign in as ada@acme.com” as an autofill suggestion, taps it, does the local gesture, and is in: no email typed, no password, no second screen. Wiring it takes two coordinated pieces:

<input name="email" autoComplete="username webauthn" />

The autocomplete value must end in webauthn. That token is what tells the browser this field can offer passkeys, and the platform requires it to be last in the list. Then, on mount, the page asks the browser to start surfacing passkeys into that field:

'use client';
import { useEffect } from 'react';
import { authClient } from '@/lib/auth-client';
export function PasskeyAutofill() {
useEffect(() => {
if (typeof PublicKeyCredential === 'undefined') return;
void PublicKeyCredential.isConditionalMediationAvailable?.().then(
(available) => {
if (available) authClient.signIn.passkey({ autoFill: true });
},
);
}, []);
return null;
}

The guard matters: PublicKeyCredential.isConditionalMediationAvailable() short-circuits cleanly on browsers that do not support the feature, so unsupported users just see a normal email field instead of a crash. The platform primitive underneath all of this is called conditional mediation (navigator.credentials.get({ mediation: 'conditional' })), and signIn.passkey({ autoFill: true }) is Better Auth’s wrapper over it. This is also why you set residentKey: 'preferred' back in the config: only discoverable credentials can populate that dropdown, because the browser has to know which passkeys exist before the user names an account.

Here is the sign-in round-trip. It mirrors the registration sequence deliberately: the same actors, with the opposite direction of trust.

Sign-in page The user picks "Sign in with a passkey"
tap / pick passkey
User agent Browser runs signIn.passkey()
Authenticator Secure enclave private key never leaves
Relying party Your server holds your public key

The user taps “Sign in with a passkey”, or focuses the autofill-enabled email field and picks their passkey from the dropdown.

Sign-in page The user picks "Sign in with a passkey"
User agent Browser runs signIn.passkey()
Authenticator Secure enclave private key never leaves
challenge
Relying party Your server issues a challenge

authClient.signIn.passkey() runs; the RP issues a time-bound random challenge.

Sign-in page The user picks "Sign in with a passkey"
User agent Browser runs signIn.passkey()
signs the challenge
Authenticator Secure enclave signs with the private key
Relying party Your server holds your public key

The browser hands the challenge to the authenticator, the user does the local gesture, and the device signs the challenge with the private key, which never leaves the enclave.

Sign-in page The user picks "Sign in with a passkey"
User agent Browser runs signIn.passkey()
Authenticator Secure enclave private key never leaves
signed assertion
Relying party Your server verifies, issues a session

The server looks up the passkey row by credentialID, verifies the signature against the stored publicKey, checks the counter (inert for synced keys) and the origin against rpID, issues a fresh session, and redirects via safeNext.

Sign-in page The user signed in — no password
User agent Browser runs signIn.passkey()
Authenticator Secure enclave private key never leaves
Relying party Your server verifies, issues a session

Signed in. No password, no second factor: one tap proved possession, biometric, and origin together.

Step 5 is the headline, so it is worth dwelling on. A password-plus-TOTP sign-in proves two things across two round-trips: something you know (the password), then something you have (the phone with the code). A passkey sign-in proves three things in a single cryptographic step: possession of the device, the biometric that unlocked it, and the origin the browser verified, all collapsed into one tap. That is why passkeys can stand in not just as a second factor but as the primary sign-in. The redirect at the end, by the way, goes through the same safeNext open-redirect guard you wrote earlier in this chapter. A passkey sign-in is no exception to the rule that any ?next= value gets validated against an allowlist before you trust it.

A few things will go wrong, and each one fails at a specific spot, so handle them where they occur rather than bundling them into one catch-all:

  • rpID mismatch. This is where the misconfiguration from the install section finally surfaces: registration succeeded, but every assertion fails, because the origin the browser sees does not match the rpID the key was scoped to. If sign-in fails for everyone the moment you ship, suspect rpID before anything else.
  • NotAllowedError and similar errors. The assertion can fail for a benign reason: the user dismissed the prompt, or there is no passkey for this origin on this device. Treat NotAllowedError as “try another way,” not a hard error, and always keep a non-passkey path visible so a user without a passkey on this device is not stranded.
  • Origin and counter verification. The library does both for you. Review any hand-rolled WebAuthn code that skips them, and remember that the counter check is weak for synced passkeys, so it is not load-bearing on its own.

Before the product decision, make sure the core property is solid. Pick the answer that captures why a passkey resists phishing.

A user is tricked onto acme-login.com, a pixel-perfect copy of the real acme.com sign-in page, and tries to sign in with their passkey. Why does the attack dead-end here when the same trick relayed a TOTP code straight through?

The browser will only hand the challenge to the authenticator when the request’s origin matches the one the passkey was registered to, so on acme-login.com no signature is ever produced for the proxy to relay.
The passkey’s private key travels to the phishing site encrypted, so the proxy captures it but can’t decrypt it in time to reuse it.
A passkey assertion expires in a much shorter window than a TOTP code, so the relayed signature is already stale by the time it reaches acme.com.
The user is trained to read the address bar, spots the look-alike domain, and cancels the prompt before the device signs.

Those two columns you flagged earlier, deviceType and backedUp, are now a decision. Passkeys come in two kinds, and the difference is where the private key lives and whether it can travel.

A synced passkey (deviceType: 'multiDevice', backedUp: true) is held by a cloud keychain such as iCloud Keychain, Google Password Manager, or 1Password. The private key replicates across all the user’s devices, end-to-end encrypted by the platform. Buy a new phone, sign in to iCloud, and the passkey is already there, with no re-registration. The trade is that the passkey’s security now rides on the cloud account: whoever controls the user’s Apple or Google account controls the passkey too. (This is also the case where counter stays 0 forever, because there is no single device to clone, so the counter check has nothing to do.)

A device-bound passkey (deviceType: 'singleDevice', backedUp: false) lives on one piece of hardware and never leaves it, such as a YubiKey or a platform credential an enterprise pins to a managed device. There is no cloud copy. The trade inverts: the key physically cannot be copied, which is exactly what high-assurance environments want, but lose the device and the credential is gone, with no sync to recover it.

Here is the call, stated plainly. Synced is the right consumer default. The dominant real-world failure for normal users is losing a device, and synced passkeys recover from that automatically while platform end-to-end encryption keeps the keys strong in transit. Device-bound is the enterprise and high-assurance choice. You reach for it when “this key physically cannot be copied off the device” is a hard requirement worth paying the recovery friction for. And because backedUp is a column you can read, your app can see which kind a user enrolled and tier its policy accordingly, for example requiring a device-bound key for admins while letting everyone else use synced ones.

That decision does not stand alone, though. It composes with the earlier question of whether you offer passkeys as primary sign-in or as a second factor on top of a password. Walk the combined decision below: the value is in the order of the questions a senior asks, not in any single ending.

Should this surface use passkeys, and how?

Notice there is no comparison table here, and that is deliberate: the walk is the decision artifact. A table would invite you to memorize cells; the walker makes you commit to the order the questions come in, which is the part that transfers.

Every passkey leaf in that walk landed on the same floor, recovery codes, and that is the non-negotiable part of shipping passkeys. Picture the failure: a user whose only credential is a single synced passkey loses access to the iCloud or Google account that holds it. The passkey is now unreachable, and they cannot sign in to reach the settings page where they would manage it. It is the exact shape of the lost-phone lockout from the TOTP lesson, and it has the exact same fix.

That fix is the recovery codes you already built. You do not rebuild anything here; passkeys are simply another consumer of the once-only recovery-code reveal from the previous lesson. The rule to carry is the one this whole chapter keeps returning to: a credential the user can lose must have a recovery path you set up before they lose it. Passwords had reset. TOTP had recovery codes. Passkeys lean on those same codes. So every passkey enrollment should also ensure the user holds recovery codes, and “this account has no recovery path” must be an explicit, loudly flagged opt-in, never something a user backs into by enabling passkeys and nothing else.

The library handles the ceremony, but the standard underneath it is deep, and the synced-versus-device-bound landscape keeps moving. These are the references worth bookmarking.