Skip to content
Chapter 53Lesson 7

Passkeys and WebAuthn

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

Every credential you have shipped so far can be handed to the wrong site by a user who thinks they are on the right one. A password is just a string, and people will type it into acme-login.com as readily as acme.com. TOTP shut out leaked and reused passwords, but not this: a live phishing proxy can ask for the six-digit code and relay it to the real site within the ninety seconds it stays valid. The weak point was never the password or the code, it is that the human can be tricked into handing a working credential to a look-alike site, and a stronger password does nothing to fix it.

A passkey closes the gap by structure instead of asking the user to be careful. It is a private key 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 nothing to type into the wrong place. By the end of this lesson you will wire Better Auth’s passkey() plugin for registration and sign-in, be able to explain 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”

Walk the attack concretely. The user clicks a link in an email and lands on acme-login.com, a pixel-perfect copy of your sign-in page 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, thanks to last lesson, demands a second factor, so the phishing site shows its own “enter your code” screen. The user reads six digits from their authenticator app and types them in. The proxy relays them to acme.com before they expire, and the attacker is inside the account.

TOTP lost because the code is a string the user can be talked into relaying. Single-use and a ninety-second expiry do not help: the attacker only needs it to survive one relay inside that window, and a live proxy clears that bar easily.

Now run the identical attack against a passkey. The look-alike page asks the browser to sign in with a passkey for acme.com. The browser checks the origin the request actually comes from, sees acme-login.com, and refuses to invoke the authenticator, because that passkey was registered to acme.com alone. The user is never prompted, 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 , the one thing TOTP could not give you.

The diagram puts the two attacks side by side. The boxes stay fixed; only whether the credential survives the attacker’s hop changes.

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.

TOTP asks the user to be the origin check: to read the address bar and judge the site real before reading 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 a signature for the wrong origin is never made.

The model is small: who the actors are, and what the two round-trips between them do.

WebAuthn , the W3C standard, defines three roles. (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. All of phishing-resistance lives here.
  • Authenticator : holds the private key and unlocks it with a local gesture, such as Face ID, Touch ID, Windows Hello, or a hardware-key tap. A platform authenticator is built into the phone or laptop; a roaming (cross-platform) authenticator is a separate device you plug in or tap, like a YubiKey.

Between these roles, everything happens through two ceremonies, the way enrollment and challenge were two flows for TOTP.

Registration mints a new credential. The browser calls navigator.credentials.create(). The authenticator generates a fresh keypair, locks the private key 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 stored public key.

That asymmetry is what makes the scheme safe. With public-key cryptography , the private key never leaves the enclave, and the server only ever holds the public key. Earlier lessons stored a hash of a secret the server had briefly seen; here the server never had the secret at all. Leak every passkey row and you authenticate nobody, because a public key verifies signatures and cannot produce them.

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 checked against that key. The private key stays in the enclave through both.

The library calls navigator.credentials.create and .get for you. What you write is authClient.passkey.addPasskey for registration and authClient.signIn.passkey for sign-in.

Now sort these facts into the ceremony each belongs to.

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

Wiring follows the magic-link and TOTP pattern: register the plugin on the server, its partner on the client, regenerate the schema. Register passkey() in the plugins array of your auth instance in lib/auth.ts, and passkeyClient() in the matching array in lib/auth-client.ts. Skip the client half and authClient.passkey.* and authClient.signIn.passkey will not exist. The one twist this time is the import paths.

The server registration carries a few config options. Most are labels; one is the most common way to get passkeys subtly wrong.

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 label shown in the OS or browser prompt, as in “Save a passkey for Acme?”. Cosmetic, not a security setting.

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 — 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, the server expects requests from. rpID is the host; 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 enables the no-typing autofill sign-in later. userVerification: 'preferred' requires the local gesture (Face ID or PIN) wherever the device supports it.

1 / 1

Misconfiguring rpID produces the hardest failure to debug: registration succeeds and sign-in silently fails. rpID must be the registrable domain your app runs on. Set rpID: 'example.com' while the app lives at app.example.com, and the browser registers a passkey, then refuses every assertion , because the key is scoped to example.com but the request comes from app.example.com. It is the same origin-binding that stopped the phishing proxy, except now the wrong origin is yours. When a passkey registers fine but dies at sign-in, check rpID first.

Like every Better Auth plugin, passkey() needs database tables: npx @better-auth/cli generate reads your config and writes the migration. It adds one table, passkey, with one row per credential. A user normally has several — a phone, a laptop, maybe a hardware key — so model this as one-user-to-many 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 columns deserve a sentence. counter is a number the authenticator bumps on every signature, and the library checks that it only grows, which catches a cloned single-device authenticator signing in parallel. It is real protection only for single-device keys: synced passkeys (the common case) report counter: 0 and never increment, so the check is inert for them. deviceType and backedUp signal whether a credential is synced across devices or pinned to one — note them now, decide on them later in this lesson.

The default flow, and the one you build first, is adding a passkey to an account the user is already signed in to, attached from a settings page. registration.requireSession is true out of the box, so the flow starts from a live session. Each step below maps to one hop in the roles diagram.

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 (hence requireSession: 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, and the browser prompts for a 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 plus attestation.

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 name, with its device type and a remove button.

Note one departure from every form in this chapter: passkey registration is not a Server Action. There is no FormData, no Zod boundary, no Result to wrap, because the browser client owns the entire WebAuthn round-trip. addPasskey talks directly to the authenticator and the Better Auth endpoint, so the action skeleton does not apply.

'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 comes from a small input, like “iPhone” or “Work laptop”, so the user can tell credentials apart.

One variation worth naming, though you will not build it here, is passkey-first onboarding: a brand-new user creating their first passkey with no prior session. That flips registration.requireSession to false and adds a server resolveUser callback telling Better Auth which user the credential belongs to. It is an opt-in for when “zero passwords, ever” is a real product goal; most products instead add passkeys from settings and let passkey sign-in take over once a credential exists.

The rest of the management surface is three one-line calls:

  • 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 prune the credential from a laptop they sold a year ago.

This ceremony pays off the registration work. There are two entry points, both calling authClient.signIn.passkey.

The explicit one is a “Sign in with a passkey” button that calls authClient.signIn.passkey() on click. Simple, and the right fallback.

The one worth building is conditional-UI autofill. The user’s passkeys appear inside the email field’s autofill dropdown, alongside saved usernames: focus the field, see “Sign in as ada@acme.com”, tap it, do the local gesture, and you are in, no typing. Wiring it takes two pieces.

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

The webauthn token tells the browser this field can offer passkeys, and the platform requires it last. Then, on mount, ask the browser to start surfacing passkeys into the 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 isConditionalMediationAvailable() guard short-circuits on browsers without the feature, so unsupported users see a normal email field. The primitive underneath is conditional mediation (navigator.credentials.get({ mediation: 'conditional' })), and signIn.passkey({ autoFill: true }) wraps it. This is why you set residentKey: 'preferred': only discoverable credentials populate the dropdown, since the browser must know which passkeys exist before the user names an account.

The sign-in round-trip mirrors registration 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 it 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 point. Password-plus-TOTP proves two things across two round-trips: something you know, then something you have. A passkey proves three in one step: possession of the device, the biometric that unlocked it, and the origin the browser verified, all in one tap. That is why a passkey can serve as the primary sign-in, not just a second factor. The redirect at the end still runs through the safeNext open-redirect guard from earlier: any ?next= value is validated against an allowlist before you trust it.

A few things will fail, each at a specific spot, so handle them where they occur:

  • rpID mismatch. The install-section misconfiguration surfaces here: 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 first.
  • NotAllowedError. The assertion can fail benignly: the user dismissed the prompt, or there is no passkey for this origin on this device. Treat NotAllowedError as “try another way,” and always keep a non-passkey path visible so a user without a passkey here is not stranded.
  • Origin and counter verification. The library does both. Review any hand-rolled WebAuthn that skips them, and note that the counter check is weak for synced passkeys.

Confirm the core property before the product decision. 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.

The two columns you flagged earlier, deviceType and backedUp, are now a decision. Passkeys come in two kinds, differing in 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 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. The trade is that its security now rides on the cloud account: whoever controls the user’s Apple or Google account controls the passkey too.

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

Synced is the right consumer default. The dominant failure for normal users is losing a device, and synced passkeys recover from that automatically while platform encryption keeps the keys strong in transit. Device-bound is the enterprise and high-assurance choice, for when “this key physically cannot be copied off the device” is a hard requirement worth the recovery friction. Because backedUp is a readable column, your app can tier its policy on it, for example requiring a device-bound key for admins while everyone else uses synced ones.

That choice composes with the earlier one: do you offer passkeys as primary sign-in or as a second factor on top of a password? The value of the walk below is in the order of the questions, not any single ending.

Should this surface use passkeys, and how?

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

That fix is the recovery codes you already built; passkeys are just another consumer of the once-only recovery-code reveal from the previous lesson. The rule this chapter keeps returning to: a credential the user can lose must have a recovery path you set up before they lose it. 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 is deep and the synced-versus-device-bound landscape keeps moving. References worth bookmarking: