Passkeys let your users sign in with Face ID, Touch ID, Windows Hello, or a hardware security key instead of a password. They're passwordless and phishing-resistant by design, and as of this week they ship in the MakerKit Next.js Supabase, Drizzle ORM, and Prisma kits. Turn them on with an environment variable (plus a migration in the Better Auth kits) and your users get a "Sign in with a passkey" button on the login screen.
This post covers two things: why passwords are quietly costing you, and exactly how to enable passkeys in each kit. Tested with Next.js 16 and the current MakerKit kit versions.
Why passwords are the weakest part of your SaaS
Every password-based SaaS inherits three problems, and none of them are your users' fault.
Phishing. A password is a shared secret the user can be tricked into typing into the wrong box. No amount of complexity rules fixes that. Attackers don't crack passwords anymore, they ask for them, and a convincing login page is enough.
Reuse. Users recycle passwords across services. When some unrelated site gets breached, those credentials get stuffed into your login form too. Your security is now bounded by the worst site your user ever signed up for.
Reset friction. Forgotten passwords mean reset emails, support tickets, and abandoned sessions. Every reset flow is a place users drop off, and every reset email is a place attackers try to intercept.
Passkeys remove the shared secret entirely. There's nothing to phish, nothing to reuse, and nothing to 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 never leaves the user's device (phone, laptop, or hardware security key) and is unlocked with biometrics like Face ID or Touch ID. Because the credential is cryptographically tied to your domain, it can't be phished or replayed on a lookalike site.
In practice the flow looks like this:
- The user authenticates locally (Face ID, Touch ID, Windows Hello, or a security key)
- The device signs a challenge from your server with a private key that never leaves it
- Your server verifies the signature against the public key it stored at registration
No password is transmitted, stored, or typed. There's no shared secret to leak.
How passkeys work in MakerKit
There's one design detail worth understanding before you flip the switch: a passkey authenticates an existing account. It is not a sign-up method.
So the model across all three kits is the same:
- The user creates an account with email/password, magic link, or OAuth
- While signed in, they register a passkey from their account settings
- On the next visit, they click "Sign in with a passkey" and authenticate with their device
That's why there's no passkey button on the sign-up page, only on sign-in. It's a deliberate choice, not a missing feature. Passkeys are disabled by default in every kit, so nothing changes until you opt in.
From here, the setup splits by kit. The Supabase kit is a single flag. The Drizzle and Prisma kits (both built on Better Auth) need a migration first, because passkeys add a database table.
How we implement passkeys in MakerKit
The implementation is the same shape in every kit: a thin UI layer on top of a hooks layer, with the WebAuthn work delegated to the provider. You don't touch WebAuthn ceremony directly.
There are four moving parts:
- 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: the kit wraps the provider's client calls in React hooks so you build screens against a stable API, not the raw WebAuthn calls. 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 kit uses Supabase's built-in WebAuthn APIs; the Drizzle and Prisma kits use the
@better-auth/passkeyplugin, registered on both server and client. - Gating: a single env flag (
NEXT_PUBLIC_AUTH_PASSKEY) controls the UI. In the Better Auth kits, a separate build-time flag (ENABLE_PASSKEY) provisions the database table, because a table can't be toggled at runtime.
Here's what your users see once it's enabled. The sign-in page gains a passkey button alongside the existing methods:
Click to expandAnd account settings gain a Passkeys card to register and manage devices:
Click to expandThe next two sections show the exact config for each kit.
Enable passkeys in the Supabase kit
The Supabase kit uses Supabase's built-in WebAuthn support, so there's no migration to run. Two things have to be true.
1. Turn on 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 still has to accept the credentials.
Once both are set, a "Sign in with Passkey" button appears on the sign-in page, and a Passkeys card shows up in personal account settings where users register and remove passkeys.
Under the hood, the kit opts into Supabase's WebAuthn browser APIs in packages/supabase/src/clients/browser-client.ts:
createBrowserClient(url, key, { auth: { experimental: { passkey: true, }, },});The UI is wired through a small set of hooks so you can build your own screens if you want to:
useSignInWithPasskey()signs an existing user inuseRegisterPasskey(userId)registers a passkey for the signed-in useruseFetchPasskeys(userId)anduseDeletePasskey(userId)list and remove passkeys
One requirement to note: WebAuthn needs a secure context. That means HTTPS in production, or localhost during development. It won't run over plain HTTP on a LAN IP.
Full reference: Supabase authentication configuration.
Enable passkeys in the Drizzle and Prisma kits
The Better Auth kits (Drizzle and Prisma) are built on @better-auth/passkey. If you're new to these kits, start with Announcing the Drizzle and Prisma Better Auth kits for the lay of the land. The difference from Supabase is that passkeys add a passkey table to your database, so there are two gates, not one. A database table can't depend on a runtime environment variable, so the table has to be provisioned ahead of time.
1. Set the build-time flag. In packages/better-auth/src/auth.features.ts:
export const ENABLE_PASSKEY = true;This constant is the single source of truth. It's read by both the schema generator (config.ts) and the runtime auth config (auth.ts). While it's false, the plugin isn't registered, the /passkey/* endpoints don't exist, and the table is never generated. This is a committed constant, not an environment variable, because it changes your 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. Same flag as the Supabase kit:
NEXT_PUBLIC_AUTH_PASSKEY=trueNow a "Sign in with a passkey" button 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 both the server (auth.ts) and client (auth-client.ts), exactly like magic link and OTP. 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). If you'd rather 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.
Set up a passkey on macOS with Touch ID
Once the feature is enabled, here's the flow your users actually go through on a Mac. This is the same in every kit, since the browser drives it, not your app.
1. Sign in with an existing method. Passkeys register against an account, so the user signs in first with email/password, magic link, or OAuth.
2. Open the Passkeys card in account settings. On the Supabase kit it's in personal account settings; on the Better Auth kits it's under Settings > Security. Click Add a passkey.
3. Approve the Touch ID prompt. The browser shows the system sheet asking to save a passkey for your domain. The user rests a finger on Touch ID to confirm.
Click to expand4. Name the device (optional). The kit stores a label so users can tell passkeys apart later, like "MacBook Touch ID". The new passkey now appears in the Passkeys card.
5. Sign in with it next time. On the sign-in page, the user clicks Sign in with a passkey, Touch ID prompts again, and they're in. No password, no email round-trip.
A couple of macOS specifics worth knowing:
- The passkey is stored in iCloud Keychain, so it syncs to the same user's iPhone and iPad automatically. They can sign in on their phone with Face ID without registering a second passkey.
- Touch ID passkeys need Safari 16+ or a Chromium browser on macOS Ventura or later. On older setups the browser falls back to a QR code for a phone-based passkey.
- During local development this works on
localhostover HTTP. Everywhere else it needs HTTPS, since WebAuthn requires a secure context.
Common mistakes (and how to avoid them)
These are the two ways passkey setups break in practice. Both are easy to avoid once you know they exist.
Setting the UI flag without the migration (Better Auth kits). If you set NEXT_PUBLIC_AUTH_PASSKEY=true but forget ENABLE_PASSKEY = true in auth.features.ts and the migration, the passkey button renders but the endpoints don't exist. The button is there; nothing works behind it. Always do the build-time flag and migration first, then the UI flag.
Wrong relying party domain in production (Better Auth kits). Passkeys are bound to a relying party (rpID), which the kit derives from NEXT_PUBLIC_SITE_URL. In development it resolves to localhost; in production it must be your real registrable domain, like app.example.com. If the rpID/origin doesn't match the URL the user is actually visiting, the browser rejects the passkey outright. Set NEXT_PUBLIC_SITE_URL correctly before you go live.
Serving over plain HTTP (Supabase kit and beyond). WebAuthn requires a secure context. Test on localhost or HTTPS, never a bare HTTP IP address.
Quick Recommendation
Passkeys are a good fit if:
- You have logged-in users who return often and want faster sign-in
- You're in a security-sensitive space where phishing resistance matters
- You want to reduce password-reset support load over time
Hold off if:
- You need an auth method available before account creation (passkeys are registered post sign-up, so keep email/password or OAuth as the entry point)
- Your users are on old devices or browsers without WebAuthn support
Our pick: Offer passkeys alongside email/password, not as a replacement. Let users opt in from settings once they have an account. You get the security and UX win for the users who want it, without locking anyone out. In the Supabase kit it's one environment variable; in the Better Auth kits it's a flag plus one migration.
Frequently Asked Questions
What is a passkey?
Why don't passkeys appear on the sign-up page?
Which MakerKit kits support passkeys?
Do I need a database migration for passkeys?
Why does my passkey get rejected in production?
Can passkeys replace passwords entirely?
Next steps
Passkeys are one piece of the auth stack MakerKit ships out of the box, alongside email/password, magic links, OAuth, and MFA. If you're still choosing an auth layer, see Better Auth vs Clerk vs NextAuth vs Supabase Auth for the tradeoffs. Setting passkeys up now? Start with the per-kit guide for your stack: Supabase, Drizzle, or Prisma. Don't have a kit yet? Grab MakerKit and get passwordless sign-in working in an afternoon.