Create new

Passkeys

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):

  1. Your server generates a random challenge
  2. The browser prompts for biometric confirmation
  3. The device signs the challenge with the private key
  4. Your server verifies the signature and stores the public key

Authentication (every sign-in):

  1. Your server generates a new challenge
  2. The browser prompts for biometric confirmation
  3. The device signs the challenge
  4. 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.

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!,
  }),
],