ยทUpdated

Passkeys in Next.js and TanStack Start: Passwordless Sign-In with MakerKit

How to add passkey (WebAuthn) sign-in with Face ID, Touch ID and Windows Hello to a Next.js or TanStack Start SaaS: one env flag in the MakerKit Supabase kits, a flag plus a migration in the Drizzle and Prisma (Better Auth) kits.

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:

  1. The user creates an account with email/password, a magic link or OAuth
  2. While signed in, they register a passkey from their account settings
  3. 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-passkey under @kit/auth/hooks.
  • Provider: the Supabase kits use Supabase Auth's WebAuthn APIs; the Drizzle and Prisma kits use the @better-auth/passkey plugin, registered on both server and client.
  • Feature flags: one env variable controls the UI: NEXT_PUBLIC_AUTH_PASSKEY in the Next.js kits, VITE_AUTH_PASSKEY in 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:

MakerKit sign-in page with a Sign in with a passkey button next to email and OAuth sign-inClick to expand

Account settings show a Passkeys card for registering and managing devices:

Passkeys card in MakerKit account settings listing registered passkeys with add and remove actionsClick to expand

How 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=true

2. 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 in
  • useRegisterPasskey(userId) registers a passkey for the signed-in user
  • useFetchPasskeys(userId) and useDeletePasskey(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:generate
pnpm --filter @kit/database drizzle:generate
pnpm --filter @kit/database drizzle:migrate

For Prisma, regenerate the schema, then migrate:

pnpm --filter @kit/better-auth schema:generate
pnpm --filter @kit/database prisma migrate dev --name add-passkey

3. Show the UI. Use the same flag as the Supabase kit:

NEXT_PUBLIC_AUTH_PASSKEY=true

A "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 user
await authClient.passkey.addPasskey({ name: 'MacBook Touch ID' });
// List the user's 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 registered passkey
await 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=true

The 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=true and 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 = true in packages/better-auth/src/auth.features.ts, run schema:generate and the migration commands from the section above (the TanStack Prisma kit uses pnpm --filter @kit/database prisma:migrate), then set VITE_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.

macOS Touch ID prompt asking to save a passkey for the app's domainClick to expand

4. 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 localhost over 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?
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 and is unlocked with biometrics like Face ID or Touch ID. Because it's tied to your domain, it can't be phished or reused on a lookalike site.
Why don't passkeys appear on the sign-up page?
A passkey authenticates an existing account, so users register one from their account settings after signing up. The sign-up page intentionally omits passkeys; the 'Sign in with a passkey' button appears on the sign-in page once the feature is enabled.
Which MakerKit kits support passkeys?
The Supabase, Drizzle ORM, and Prisma kits, on both Next.js and TanStack Start. The Supabase kits use Supabase's built-in WebAuthn support and need only an environment variable. The Drizzle and Prisma kits are built on Better Auth and require a build-time flag plus a database migration before showing the UI.
How do I enable passkeys on TanStack Start?
The same way as the Next.js kits, with a different env prefix: TanStack Start builds with Vite, so the UI flag is VITE_AUTH_PASSKEY instead of NEXT_PUBLIC_AUTH_PASSKEY, and the relying party domain comes from VITE_SITE_URL. The hooks, the @better-auth/passkey plugin, and the build-time ENABLE_PASSKEY gate are identical, because the kits share one SaaS architecture across frameworks.
Do I need a database migration for passkeys?
Only in the Drizzle and Prisma kits, where passkeys add a 'passkey' table. Set ENABLE_PASSKEY=true in auth.features.ts, regenerate the schema, and run the migration before turning on the UI flag (NEXT_PUBLIC_AUTH_PASSKEY, or VITE_AUTH_PASSKEY on TanStack Start). The Supabase kits need no migration.
Why does my passkey get rejected in production?
Most often because the relying party domain is wrong. In the Better Auth kits, rpID is derived from your site URL: NEXT_PUBLIC_SITE_URL on Next.js, VITE_SITE_URL on TanStack Start. If it doesn't match the domain the user is visiting, the browser rejects the passkey. Set it to your real registrable domain. Also confirm you're on HTTPS, since WebAuthn requires a secure context.
Can passkeys replace passwords entirely?
Not yet, in most cases. Because passkeys are registered after account creation, you still need an initial sign-up method like email/password or OAuth. Offer passkeys as an additional, faster, phishing-resistant option rather than the only way in.

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.