Passkeys are WebAuthn credentials that let users sign in with Face ID, Touch ID, Windows Hello or a hardware security key instead of a password. The MakerKit Supabase, Drizzle and Prisma kits ship passkey sign-in on both Next.js and TanStack Start. In the Supabase kits you enable it with one environment variable (plus a toggle in the Supabase Dashboard); in the Drizzle and Prisma kits, which use Better Auth, you also set a build-time flag and run a database migration.
This post explains what passkeys protect against, then gives the exact setup steps for each kit. Tested with Next.js 16, TanStack Start and the current MakerKit kit versions.
Why replace passwords with passkeys?
Passkeys remove the shared secret that makes passwords vulnerable to phishing, credential reuse and reset-flow attacks. Password-based SaaS apps have three problems that users can't fix by choosing better passwords:
Phishing. A password is a secret the user can be tricked into typing on a fake login page. Complexity rules don't help, because the attacker receives the password exactly as typed.
Reuse. Users reuse passwords across services. When another site is breached, attackers try those email/password pairs against your login form (credential stuffing). Your app is exposed to breaches you have no control over.
Password resets. Forgotten passwords create reset emails and support tickets, and some users abandon the sign-in. Every reset email is also a target for account takeover.
A passkey has no shared secret: the server stores only a public key, so there is nothing to phish, reuse or reset.
What is a passkey?
A passkey is a WebAuthn credential bound to a device and a website, used to sign in without a password. The private key stays on the user's device (phone, laptop or hardware security key) and is unlocked with Face ID, Touch ID, Windows Hello or a PIN. The browser only uses the credential on the domain it was created for, so a lookalike phishing site can't request it.
Signing in with a passkey works like this:
- The user unlocks the passkey locally (Face ID, Touch ID, Windows Hello or a security key)
- The device signs a challenge from your server with the private key
- Your server verifies the signature against the public key it stored when the passkey was registered
No password is sent, stored or typed.
How passkeys work in MakerKit
In MakerKit, a passkey signs in to an existing account; it is not a sign-up method. The flow is the same in every kit:
- The user creates an account with email/password, a magic link or OAuth
- While signed in, they register a passkey from their account settings
- On later visits, they click "Sign in with a passkey" and authenticate with their device
For this reason the passkey button appears only on the sign-in page, not on sign-up. Passkeys are disabled by default in every kit, so nothing changes until you enable them.
Each kit's implementation has four parts. You don't write WebAuthn registration or verification code yourself; the auth provider handles it.
- UI: a "Sign in with a passkey" button on the sign-in page, and a Passkeys card in account settings to register, list and remove passkeys. Both render only when the feature flag is on.
- Hooks: React hooks that wrap the provider's client calls, so custom screens don't call WebAuthn APIs directly. Supabase:
useSignInWithPasskey,useRegisterPasskey,useFetchPasskeys,useDeletePasskey. Better Auth:use-sign-in-with-passkey,use-add-passkey,use-list-passkeys,use-delete-passkeyunder@kit/auth/hooks. - Provider: the Supabase kits use Supabase Auth's WebAuthn APIs; the Drizzle and Prisma kits use the
@better-auth/passkeyplugin, registered on both server and client. - Feature flags: one env variable controls the UI:
NEXT_PUBLIC_AUTH_PASSKEYin the Next.js kits,VITE_AUTH_PASSKEYin the TanStack Start kits. The Better Auth kits add a build-time constant (ENABLE_PASSKEY) that creates the database table, because a table can't be switched on and off at runtime.
With the feature enabled, the sign-in page shows a passkey button next to the other sign-in methods:
Click to expandAccount settings show a Passkeys card for registering and managing devices:
Click to expandHow to enable passkeys in the Supabase kit
To enable passkeys in the Next.js Supabase kit, set NEXT_PUBLIC_AUTH_PASSKEY=true and enable WebAuthn in the Supabase Dashboard. No migration is needed because Supabase Auth stores the credentials.
1. Show the UI. Set the environment variable:
NEXT_PUBLIC_AUTH_PASSKEY=true2. Enable WebAuthn in Supabase. In the Supabase Dashboard, go to Authentication > Sign In / Providers and enable WebAuthn for your project. The environment variable only renders the UI; Supabase must also be configured to accept the credentials.
With both set, a "Sign in with Passkey" button appears on the sign-in page, and a Passkeys card appears in personal account settings, where users register and remove passkeys.
The kit opts into Supabase's WebAuthn browser APIs, which are still marked experimental in supabase-js, in packages/supabase/src/clients/browser-client.ts:
createBrowserClient(url, key, { auth: { experimental: { passkey: true, }, },});If you want to build your own screens, use these hooks:
useSignInWithPasskey()signs an existing user inuseRegisterPasskey(userId)registers a passkey for the signed-in useruseFetchPasskeys(userId)anduseDeletePasskey(userId)list and remove passkeys
WebAuthn requires a secure context: HTTPS in production, or localhost in development. It does not work over plain HTTP on a LAN IP address.
Full reference: Supabase authentication configuration. To add a second factor for password sign-ins, see multi-factor authentication with Supabase.
How to enable passkeys in the Drizzle and Prisma kits (Better Auth)
To enable passkeys in the Drizzle and Prisma kits, set ENABLE_PASSKEY = true, regenerate the schema, run the migration, then set NEXT_PUBLIC_AUTH_PASSKEY=true. Both kits use Better Auth with the @better-auth/passkey plugin. If you haven't used these kits before, Announcing the Drizzle and Prisma Better Auth kits gives an overview.
Unlike Supabase, the plugin adds a passkey table to your database. A table can't depend on a runtime environment variable, so there are two flags: a build-time constant that creates the table and registers the plugin, and the runtime UI flag.
1. Set the build-time flag. In packages/better-auth/src/auth.features.ts:
export const ENABLE_PASSKEY = true;Both the schema generator (config.ts) and the runtime auth config (auth.ts) read this constant. While it's false, the plugin isn't registered, the /passkey/* endpoints don't exist and the table is never generated. It is a committed constant rather than an environment variable because it changes your database schema.
2. Generate the schema and run the migration.
For Drizzle, regenerate the Better Auth schema, then generate and apply the migration:
pnpm --filter @kit/better-auth schema:generatepnpm --filter @kit/database drizzle:generatepnpm --filter @kit/database drizzle:migrateFor Prisma, regenerate the schema, then migrate:
pnpm --filter @kit/better-auth schema:generatepnpm --filter @kit/database prisma migrate dev --name add-passkey3. Show the UI. Use the same flag as the Supabase kit:
NEXT_PUBLIC_AUTH_PASSKEY=trueA "Sign in with a passkey" button now appears on the sign-in page, and a Passkeys card appears under Settings > Security for registering, viewing and removing passkeys.
The plugin is configured in packages/better-auth/src/plugins/passkey.ts and registered on the server (auth.ts) and the client (auth-client.ts), the same way as the magic link and OTP plugins. The kit wraps the Better Auth client calls in hooks under @kit/auth/hooks (use-add-passkey, use-list-passkeys, use-delete-passkey, use-sign-in-with-passkey). To call the client directly:
// While signed in: register a passkey for the current userawait authClient.passkey.addPasskey({ name: 'MacBook Touch ID' });// List the user's passkeysconst { data: passkeys } = await authClient.passkey.listUserPasskeys();// Remove a passkeyawait authClient.passkey.deletePasskey({ id: passkeyId });// On a later visit: sign in with a registered passkeyawait authClient.signIn.passkey();Full reference: Drizzle auth methods and Prisma auth methods.
How to enable passkeys in TanStack Start
Passkeys in the TanStack Start kits (Supabase, Drizzle and Prisma) use the same hooks, the same @better-auth/passkey plugin and the same two-flag setup as the Next.js kits, because the kits share one architecture across frameworks. Two things differ.
The env prefix. TanStack Start builds with Vite, so the UI flag is VITE_AUTH_PASSKEY:
VITE_AUTH_PASSKEY=trueThe site URL variable. The relying party domain comes from VITE_SITE_URL instead of NEXT_PUBLIC_SITE_URL. Use localhost in development and your real registrable domain in production.
The remaining steps depend on the database layer:
- TanStack Supabase: set
VITE_AUTH_PASSKEY=trueand enable WebAuthn in the Supabase Dashboard under Authentication > Sign In / Providers. No migration, as in the Next.js Supabase kit. - TanStack Drizzle and Prisma: set
ENABLE_PASSKEY = trueinpackages/better-auth/src/auth.features.ts, runschema:generateand the migration commands from the section above (the TanStack Prisma kit usespnpm --filter @kit/database prisma:migrate), then setVITE_AUTH_PASSKEY=true.
Full reference: TanStack Supabase authentication configuration, TanStack Drizzle auth methods and TanStack Prisma auth methods. For the wider auth setup on this framework, see Better Auth + TanStack Start and TanStack Start + Supabase Auth.
How to set up a passkey on macOS with Touch ID
After you enable the feature, a user on a Mac registers and uses a passkey in five steps. The steps are the same in every kit, because the browser and operating system show the prompts.
1. Sign in with an existing method. Passkeys are registered to an account, so the user first signs in with email/password, a magic link or OAuth.
2. Open the Passkeys card in account settings. In the Supabase kits it's in personal account settings; in the Better Auth kits it's under Settings > Security. Click Add a passkey.
3. Approve the Touch ID prompt. The browser shows a system dialog asking to save a passkey for your domain. The user confirms with Touch ID.
Click to expand4. Name the device (optional). The kit stores a label such as "MacBook Touch ID" so users can tell passkeys apart. The new passkey then appears in the Passkeys card.
5. Sign in with the passkey. On the sign-in page, the user clicks Sign in with a passkey, confirms with Touch ID and is signed in without a password or an email link.
macOS details:
- A passkey saved to iCloud Keychain (the default in Safari) syncs to the user's iPhone and iPad, so they can sign in there with Face ID without registering a second passkey. Chrome may instead offer to save it in Google Password Manager.
- iCloud Keychain passkeys require macOS Ventura or later (Safari 16+). On older systems, the browser can show a QR code so the user can sign in with a passkey stored on their phone.
- In local development, passkeys work on
localhostover HTTP. Everywhere else they need HTTPS.
Common passkey setup mistakes
Most passkey setups fail for one of three reasons:
Setting the UI flag without the migration (Better Auth kits). If you set NEXT_PUBLIC_AUTH_PASSKEY (or VITE_AUTH_PASSKEY) but skip ENABLE_PASSKEY = true in auth.features.ts and the migration, the passkey button renders but the /passkey/* endpoints don't exist, so every attempt fails. Set the build-time flag and run the migration first, then turn on the UI flag.
Wrong relying party domain in production (Better Auth kits). A passkey is bound to a relying party ID (rpID), which the kit derives from your site URL: NEXT_PUBLIC_SITE_URL on Next.js, VITE_SITE_URL on TanStack Start. In development it resolves to localhost; in production it must be your real registrable domain, such as app.example.com. If the rpID or origin doesn't match the URL the user is visiting, the browser rejects the passkey.
Serving over plain HTTP (all kits). WebAuthn requires a secure context. Test on localhost or over HTTPS, never on a plain HTTP IP address.
Should you enable passkeys?
Offer passkeys alongside email/password or OAuth, not as a replacement. Users opt in from settings once they have an account, and nobody loses access.
Enable passkeys if:
- Your users sign in often and would benefit from faster sign-in
- Your product handles sensitive data and phishing resistance matters
- You want fewer password-reset requests
Wait if:
- You need a sign-in method available before an account exists (passkeys are registered after sign-up, so keep email/password or OAuth for sign-up)
- Many of your users are on old devices or browsers without WebAuthn support
The setup cost is small: one environment variable in the Supabase kits, and a flag plus one migration in the Better Auth kits, on both Next.js and TanStack Start.
Frequently Asked Questions
What is a passkey?
Why don't passkeys appear on the sign-up page?
Which MakerKit kits support passkeys?
How do I enable passkeys on TanStack Start?
Do I need a database migration for passkeys?
Why does my passkey get rejected in production?
Can passkeys replace passwords entirely?
Next steps
MakerKit kits also include email/password, magic links, OAuth and MFA. If you're still choosing an auth provider, see Better Auth vs Clerk vs NextAuth vs Supabase Auth.
Per-kit passkey guides on Next.js: Supabase, Drizzle and Prisma. On TanStack Start: Supabase, Drizzle and Prisma.
If you don't have a kit yet, see the MakerKit kits or the TanStack Start kit.