Supabase Auth can sign users in with a passkey, so your Next.js app can offer Face ID, a fingerprint or a security key instead of a password. Each one is a WebAuthn passkey: a key pair whose private half stays on the user's device or in their password manager, while Supabase stores only the public half.
How do you add it? Turn passkeys on in your Supabase project and set the relying party to your domain. Opt in with experimental: { passkey: true } on the browser client, call registerPasskey() from a signed-in settings page, and put a button on the sign-in page that calls signInWithPasskey(). Supabase marks the API as experimental, so pin your @supabase/supabase-js version and retest when you upgrade. Here is each step, with the code.
WebAuthn vs passkey: what you are adding
WebAuthn is the W3C standard behind all of this, and the browser API that runs it: navigator.credentials.create() to make a credential and navigator.credentials.get() to use one. A passkey is the credential it creates. In Supabase's words, the user proves they hold a private key "using biometrics, a PIN, or a hardware security key", and the matching public key is registered with Supabase Auth.
Three properties matter for your app:
- It is bound to your domain. Each passkey belongs to one relying party ID, such as
example.com. A lookalike phishing site can't use it, because the browser won't offer it there. - Nothing reusable is stored on your side. Supabase keeps a public key, which is useless without the private one. There is no password to leak, reuse or reset.
- The user doesn't type an email. Supabase uses discoverable credentials, so the authenticator picks the account. A passkey sign-in is one button and one prompt.
Passkeys vs passwords is not a full replacement on day one, though. A user on a borrowed computer, or one whose passkey doesn't sync to their new phone, still needs another way in. Keep a password, a magic link or Google sign-in next to the passkey button.
Step 1: turn on passkeys in Supabase
In the dashboard, open Authentication → Passkeys, turn on passkey authentication and fill in the relying party:
- Relying Party Display Name: the name shown in the passkey prompt.
- Relying Party ID: the bare domain, such as
example.com, with no scheme, port or path. - Relying Party Origins: up to five origins, comma-separated. They must use HTTPS (loopback addresses such as localhost are the exception), and each hostname must be the RP ID or a subdomain of it.
For the local stack, the same settings go in supabase/config.toml:
[auth.passkey] enabled = true [auth.webauthn] rp_display_name = "My App" rp_id = "localhost" rp_origins = ["http://localhost:3000"]
Choose the production RP ID before anyone enrolls. Supabase's passkey guide warns that changing it makes every existing passkey unusable. Use the root domain, example.com, so that app.example.com can be one of the origins. A preview deployment on a different domain can't use those passkeys at all, so test on localhost or a staging subdomain.
Step 2: opt in on the browser client
Passkey support needs @supabase/supabase-js 2.105.0 or later, and the methods throw unless you opt in when you create the client. With @supabase/ssr, pass the flag to createBrowserClient:
// lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr';
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{ auth: { experimental: { passkey: true } } },
);
}
The ceremony runs in the browser, so the browser client is the one that needs the flag. Your Server Components, route handlers and proxy.ts keep the server client from Supabase's Next.js server-side auth guide, unchanged. The one exception is a secret-key client that calls the admin passkey methods covered below: it needs the same flag.
Step 3: let signed-in users register a passkey
A passkey is added to an account that already exists. Supabase requires a signed-in, confirmed, non-anonymous user, and SSO users can't register one. The natural place is a security section in account settings, with an optional prompt after the first sign-in.
registerPasskey() runs the whole ceremony: it fetches a challenge, opens the browser's prompt and sends the signed response back to Supabase. It must run in a Client Component:
'use client';
import { createClient } from '@/lib/supabase/client';
export function AddPasskeyButton() {
async function addPasskey() {
const supabase = createClient();
const { data, error } = await supabase.auth.registerPasskey();
if (error) {
console.error(error.message);
return;
}
console.log('Passkey added:', data.friendly_name ?? data.id);
}
return (
<button type="button" onClick={addPasskey}>
Add a passkey
</button>
);
}
Supabase names each passkey from the authenticator that made it, such as iCloud Keychain, Google Password Manager or 1Password, so a list of them makes sense to the user without extra work. To build that list, supabase.auth.passkey.list() returns each passkey's id, name and dates. passkey.update({ passkeyId, friendlyName }) renames one, and passkey.delete({ passkeyId }) removes one.
Step 4: add a passkey button to the sign-in page
signInWithPasskey() takes no email. The browser shows the user's passkeys for your domain, they confirm with their fingerprint, face or PIN, and Supabase returns a session:
'use client';
import { useRouter } from 'next/navigation';
import { createClient } from '@/lib/supabase/client';
export function PasskeySignInButton() {
const router = useRouter();
async function signIn() {
const supabase = createClient();
const { error } = await supabase.auth.signInWithPasskey();
if (error) {
// the user closed the prompt, it timed out, or it was aborted
const cancelled =
error.name === 'NotAllowedError' ||
error.code === 'ERROR_CEREMONY_ABORTED';
if (!cancelled) {
console.error(error.message);
}
return;
}
router.replace('/dashboard');
}
return (
<button type="button" onClick={signIn}>
Sign in with a passkey
</button>
);
}
The browser client from @supabase/ssr stores the new session in cookies. Once you navigate, proxy.ts and your Server Components read it like any other session, and row-level security applies to the user as usual. Nothing on the server changes for passkeys.
Three details make the button pleasant to use:
- A closed prompt is not an error. When the user dismisses the prompt or lets it time out, the browser raises
NotAllowedError, and supabase-js passes it on with that name and the codeERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY.ERROR_CEREMONY_ABORTEDmeans the ceremony was aborted, for example by a second click. Treat both as a quiet cancel, not a red alert. - Show the errors that are real. Supabase can return
webauthn_credential_not_foundfor a passkey it doesn't know,email_not_confirmed,user_banned, orpasskey_disabledif the project setting is off. - Keep the other methods visible. A user without a passkey on this device needs the password form or a magic link right next to the button.
Support and edge cases
Users lose phones. On the server, a client built on the secret key, with the same experimental: { passkey: true } flag, can call supabase.auth.admin.passkey.listPasskeys({ userId }) and deletePasskey({ userId, passkeyId }) to see and revoke a user's passkeys. Keep that code in server-only modules, like every other use of the secret key.
Supabase also caps how many passkeys one account can hold, and returns too_many_passkeys at the limit. The guide doesn't state the number, so let the error message reach the user.
If you build this with a coding agent, give it Supabase's passkey guide and tell it which client file to change. It can write the components, but someone has to click through the real browser prompts on a real device, because a terminal can't simulate them.
How the kit does it
shipanysaas ships passkey sign-in for the web app, switched off by default. You set NEXT_PUBLIC_AUTH_PASSKEY=true and turn on passkeys in your Supabase project. The local stack already has them on in apps/web/supabase/config.toml, with rp_id = "localhost" and http://localhost:3000 as the origin.
The browser client in packages/supabase/src/clients/browser-client.ts opts in to the experimental API. Four hooks in packages/supabase/src/hooks/ wrap the calls: use-sign-in-with-passkey, use-register-passkey, use-fetch-passkeys and use-delete-passkey. With the flag on, the sign-in page shows a passkey button next to the OAuth buttons and points out the method a user signed in with last time. Account settings list each passkey with its name and date, with buttons to add and delete them. The phone app doesn't have passkeys yet.
The live demo runs with password sign-in only, so you can't try passkeys there. It does show the account settings and team pages around them. Setup notes are in Two-step sign-in and passkeys, and the plans are on the pricing page.
Questions people ask
Are Supabase passkeys ready for production?
Supabase labels the feature experimental and says the API may change without notice. Pin the library version, read the changelog before upgrades, and keep another sign-in method available so a change can't lock anyone out.
Can one user have several passkeys?
Yes. Someone can add one on a laptop and another on a phone. passkey.list() returns all of them, and each has its own id for renaming or deleting.
Do passkeys work on preview deployments?
Only on hostnames that are your RP ID or a subdomain of it, and that appear in your list of origins. A preview URL on another domain can't use passkeys made for example.com.
What if I need to change my domain later?
Plan for users to register again. Passkeys are tied to the RP ID they were made for, and Supabase says changing it makes every existing passkey unusable.