Passkeys
Passwordless, phishing-resistant authentication with passkeys (WebAuthn).
Passkeys let users sign in with their device's biometrics (Face ID, Touch ID, Windows Hello) or a hardware security key instead of a password. They are passwordless and phishing-resistant by design.
Enable Passkeys
Passkeys are disabled by default. Enabling them has two gates — a build-time flag and a runtime flag — because the passkey database table must be generated ahead of time and can't depend on a runtime environment variable.
1. Flip the build-time flag
Set ENABLE_PASSKEY to true in packages/better-auth/src/auth.features.ts:
export const ENABLE_PASSKEY = true;
This constant is read by both the runtime auth config (auth.ts) and the schema-generation entrypoint (config.ts). When false, the passkey plugin is not registered server-side at all — the /passkey/* endpoints don't exist, and the passkey table is not generated. Disabling is a real boundary, not just a hidden UI.
2. Generate the table and migrate
With the flag on, generate the Better Auth schema (now including the passkey table) and apply the Prisma migration:
pnpm --filter @kit/better-auth schema:generate pnpm --filter @kit/database prisma migrate dev --name add-passkey
3. Show the UI
Set the public flag so the sign-in button and settings card render:
NEXT_PUBLIC_AUTH_PASSKEY=true
When all three steps are done:
- A "Sign in with a passkey" button appears on the sign-in page, alongside the other enabled methods.
- A Passkeys card appears under Settings → Security, where signed-in users register, view, and remove passkeys.
How It Works
A passkey is bound to an existing account, so users register one after they already have an account:
- User signs up / signs in with another method (email & password, magic link, OAuth)
- User opens Settings → Security and clicks Add a passkey
- The browser prompts to create the passkey using biometrics or a security key
- On the next visit, the user clicks Sign in with a passkey and authenticates with their device
Configuration Notes
- Relying party domain — the passkey is tied to your site's domain, derived from
NEXT_PUBLIC_SITE_URL. In development this islocalhost; in production it must be your real domain (e.g.app.example.com). A mismatch causes the browser to reject the passkey. - Keep the two flags in sync —
ENABLE_PASSKEYenables the server plugin and table;NEXT_PUBLIC_AUTH_PASSKEYenables the UI. With only the UI flag on, the buttons render but the endpoints return errors. With only the build flag on, the feature works but is never surfaced to users.
Client Usage
import { authClient } from '@kit/better-auth/client';
// While signed in: register a passkey
await authClient.passkey.addPasskey({ name: 'MacBook Touch ID' });
// List registered passkeys
const { data: passkeys } = await authClient.passkey.listUserPasskeys();
// Remove a passkey
await authClient.passkey.deletePasskey({ id: passkeyId });
// On a later visit: sign in with a passkey
await authClient.signIn.passkey();
The kit also ships hooks under @kit/auth/hooks: use-add-passkey, use-list-passkeys, use-delete-passkey, and use-sign-in-with-passkey.
When to Use Passkeys
Good for:
- Phishing-resistant, passwordless sign-in
- Users on modern devices with biometrics
- Reducing password-reset support load
Consider alternatives when:
- Users are on shared or older devices without authenticators
- You need a sign-in method available before account creation (passkeys are registered after sign-up)