Passwordless authentication using biometrics or hardware keys — built on the WebAuthn standard.
Passkeys let users authenticate with Face ID, Touch ID, Windows Hello, or a hardware security key instead of a password. They're phishing-resistant by design — the credential is cryptographically bound to your domain, so a fake login page can never steal it.
Under the hood it's WebAuthn (Web Authentication API), handled entirely by Better Auth's passkey plugin. No third-party service required.
How it works
Passkeys use asymmetric cryptography. Your server stores only a public key — the private key never leaves the user's device.
Registration (one time per device):
- Your server generates a random challenge
- The browser prompts for biometric confirmation
- The device signs the challenge with the private key
- Your server verifies the signature and stores the public key
Authentication (every sign-in):
- Your server generates a new challenge
- The browser prompts for biometric confirmation
- The device signs the challenge
- Your server verifies the signature against the stored public key → session created
No password, no OTP, no phishing vector.
Configuration
import { betterAuth } from "better-auth";
import { passkey } from "better-auth/plugins/passkey";
export const auth = betterAuth({
// ...
plugins: [
passkey({
rpID: process.env.PASSKEY_RP_ID!, // your domain, e.g. "yourdomain.com"
rpName: "launch.now", // shown in the browser prompt
origin: process.env.NEXT_PUBLIC_APP_URL!, // must match the request origin exactly
}),
],
});# Development
PASSKEY_RP_ID=localhost
NEXT_PUBLIC_APP_URL=http://localhost:3000
# Production
PASSKEY_RP_ID=yourdomain.com
NEXT_PUBLIC_APP_URL=https://yourdomain.com
rpID must be the bare domain — no protocol, no port, no path. In
development use localhost exactly. In production use yourdomain.com. A
mismatch causes all passkey operations to fail silently.
Database migration
The passkey plugin needs its own table. Run after updating auth.ts:
npx better-auth migrate
This creates a passkey table with: id, userId, credentialId, publicKey, counter, deviceType, backedUp, transports.
File structure
src/components/features/auth/passkeys/
add-passkey-dialog.tsx ← register a new passkey
passkey-list.tsx ← list, rename, delete existing passkeys
passkey-sign-in-button.tsx ← sign in with passkey (explicit trigger)
Registering a passkey
Users add a passkey from their account settings. They can register multiple passkeys — one per device is the recommended pattern.
const { data, error } = await authClient.passkey.addPasskey({
authenticatorAttachment: "platform",
// "platform" → built-in biometric (Face ID, Touch ID, Windows Hello)
// "cross-platform" → external key (YubiKey, phone as security key)
});
if (error) {
// Common errors:
// "NotAllowedError" → user cancelled the browser prompt
// "InvalidStateError" → passkey already registered on this device
}
"platform" is the right default for most users. Offer "cross-platform" as
an option for power users who want a hardware key or want to use their phone
on a desktop.
Signing in with a passkey
Two patterns — conditional UI (autofill) and explicit button.
Conditional UI (recommended)
The browser shows a passkey suggestion in the email input's autofill dropdown automatically. Users who have a passkey registered see it without any extra UI.
import { useEffect } from "react";
import { authClient } from "@/lib/auth-client";
import { useRouter } from "next/navigation";
export function PasskeyConditionalUI() {
const router = useRouter();
useEffect(() => {
// Start conditional mediation — browser decides when to show the prompt
authClient.passkey
.signIn({
autoFill: true,
callbackURL: "/dashboard",
})
.then(({ error }) => {
if (!error) router.push("/dashboard");
});
}, []);
// This component renders nothing — the browser handles the UI
return null;
}
Mount this component on your sign-in page alongside the email input.
Explicit button
For users who want to sign in with a passkey without typing their email:
const { error } = await authClient.passkey.signIn({
callbackURL: "/dashboard",
});
Listing and deleting passkeys
Show registered passkeys in account settings so users can manage them.
// List all passkeys for the current user
const { data: passkeys } = await authClient.passkey.listPasskeys();
// passkeys → Array<{ id, name, createdAt, deviceType, backedUp }>
// Delete a passkey
await authClient.passkey.deletePasskey({
id: passkey.id,
});
Let users name their passkeys (e.g. "MacBook Pro", "iPhone 15") so they can identify which device each one belongs to. This makes it easier to revoke the right one if a device is lost.
Combining with 2FA
Passkeys and TOTP 2FA can coexist. A user can have both configured — passkey sign-in bypasses the TOTP step because the biometric check already provides the second factor. Password sign-in still requires the TOTP code.
In auth.ts, include both plugins:
import { twoFactor } from "better-auth/plugins"
import { passkey } from "better-auth/plugins/passkey"
plugins: [
twoFactor({ issuer: "launch.now" }),
passkey({
rpID: process.env.PASSKEY_RP_ID!,
rpName: "launch.now",
origin: process.env.NEXT_PUBLIC_APP_URL!,
}),
],Auth, billing, orgs, and emails — all wired up. Clone and deploy in minutes.
Get launch.now