# How to Add Biometric Login to Your App — Copy-Paste Prompts Inside

- Tool: App Features
- Last updated: July 2026

## TL;DR

Biometric login for web apps uses WebAuthn passkeys — a browser-native API that stores a credential on the user's device and verifies identity with Face ID or Touch ID. No third-party service needed; the decoding runs on-device. Build time is 3–5 hours with V0 or Lovable. Running cost is $0–$25/mo (Supabase for credential storage). The hard part is the cryptographic challenge flow — use @simplewebauthn/server and @simplewebauthn/browser, never hand-roll it.

## What Biometric Login Actually Is

Biometric login on the web means WebAuthn passkeys — not sending a fingerprint to your server. The user's device (phone, laptop, or security key) holds a private key. Your server holds the matching public key. On login, the browser asks the OS to sign a challenge with the private key using Face ID or Touch ID, and your server verifies the signature. The biometric itself never leaves the device. This is stronger than passwords (no phishing, no credential stuffing) and faster than 2FA. Passkeys are now supported on Chrome 108+, Safari 16+, and Edge 109+. The product decisions are: how many devices a user can register, what happens when they get a new phone, and whether password login stays available as a fallback.

## Anatomy of the Feature

Six components handle biometric login. Three live in the browser, two on the server, one in the database. The cryptographic handshake is the part AI tools get wrong most often.

- **WebAuthn / Passkeys API** (backend): navigator.credentials.create() registers a new passkey; navigator.credentials.get() authenticates with an existing one. Both are built into modern browsers — no SDK required for the browser side of these calls. The @simplewebauthn/browser package provides helper functions that handle the base64url encoding and options formatting, significantly reducing the chance of subtle encoding bugs.
- **Challenge generator** (backend): A Supabase Edge Function generates a cryptographically random challenge (at least 32 bytes via crypto.getRandomValues()) before each registration or authentication attempt. The challenge is single-use: stored temporarily in a challenges table and deleted immediately after verification. Challenge TTL is 60 seconds — expired challenges must trigger a re-request, not a retry loop.
- **Credential storage** (data): A credentials table stores the user_id, credential_id (bytea, unique), public_key (bytea), counter (bigint), device_name (text), created_at, and last_used_at for each registered authenticator. The counter field is critical: it must increment on every successful authentication. A counter that does not increment indicates a cloned authenticator.
- **Biometric prompt UI** (ui): A single button ('Sign in with Face ID / Touch ID') triggers navigator.credentials.get(). The actual biometric dialog is rendered by the OS — your app has no control over its appearance. Show only this button on devices where PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable() returns true; show the password form by default on devices where it returns false.
- **Device management panel** (ui): An account settings section listing all registered credentials for the current user, fetched from the credentials table (RLS-scoped to auth.uid() = user_id). Each row shows device_name and created_at. A 'Revoke' button deletes the credential row; the device can no longer sign in biometrically until re-enrolled.
- **Fallback auth flow** (ui): A 'Use password instead' link always visible beneath the biometric button. When WebAuthn throws NotAllowedError (user cancelled), NotSupportedError (browser unsupported), or InvalidStateError (credential not found on this device), the app gracefully redirects to the standard email/password form without showing a raw error message.

## Data model

Two tables are needed: one for WebAuthn credentials, one for single-use challenges. Run in the Supabase SQL editor:

```sql
create table public.webauthn_credentials (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete cascade not null,
  credential_id text unique not null,
  public_key text not null,
  counter bigint not null default 0,
  device_name text,
  created_at timestamptz not null default now(),
  last_used_at timestamptz
);

alter table public.webauthn_credentials enable row level security;

create policy "Users can view own credentials"
  on public.webauthn_credentials for select
  using (auth.uid() = user_id);

create policy "Users can delete own credentials"
  on public.webauthn_credentials for delete
  using (auth.uid() = user_id);

-- Insert and counter updates go through Edge Functions using service role key

create table public.webauthn_challenges (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete cascade not null,
  challenge text not null,
  expires_at timestamptz not null default (now() + interval '60 seconds'),
  created_at timestamptz not null default now()
);

alter table public.webauthn_challenges enable row level security;
-- No client SELECT on challenges; all reads via Edge Function with service role

create index webauthn_creds_user_idx on public.webauthn_credentials (user_id);
create index webauthn_challenges_expiry_idx on public.webauthn_challenges (expires_at);
```

Insert and counter updates for credentials must go through a Supabase Edge Function using the service role key — never the anon key — so the client cannot forge credential records. Schedule a cleanup job or Supabase cron to delete expired challenge rows.

## Build paths

### Lovable — fit 3/10, 3–5 hours

Viable but requires multiple prompt iterations. Lovable can scaffold the WebAuthn UI and Edge Functions, but the cryptographic challenge flow and @simplewebauthn/server Deno import map require careful follow-up prompts.

1. Create the webauthn_credentials and webauthn_challenges tables by running the SQL above in Supabase Dashboard → SQL Editor
2. Paste the prompt below in Lovable Agent Mode; let it generate the registration and authentication pages and Edge Functions
3. In Lovable Cloud tab → Edge Functions, verify two functions exist: webauthn-register and webauthn-authenticate
4. Click Publish to get a real HTTPS URL — WebAuthn will throw NotAllowedError in the preview iframe; all testing must happen on the published URL
5. Test registration and authentication on Chrome and Safari from a real phone or laptop with a fingerprint reader

Starter prompt:

```
Add WebAuthn passkey (biometric) login to this app using @simplewebauthn/browser on the client and @simplewebauthn/server in Supabase Edge Functions. Flow 1 — Registration (after user is already logged in with email/password): 1) Check if PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable() returns true; if false, show a message that this browser does not support passkeys. 2) Call a webauthn-register-start Edge Function that generates a random challenge (crypto.getRandomValues 32 bytes), saves it to webauthn_challenges table with 60-second expiry, and returns PublicKeyCredentialCreationOptions. 3) Call @simplewebauthn/browser startRegistration() with those options — this triggers the OS biometric prompt. 4) Send the response to a webauthn-register-finish Edge Function that verifies with @simplewebauthn/server verifyRegistrationResponse(), saves credential_id, public_key, and counter to webauthn_credentials table, and deletes the challenge. Flow 2 — Authentication (on the login page): 1) Button 'Sign in with Face ID / Touch ID' visible only if platform authenticator is available; always show 'Use password instead' link below it. 2) Call webauthn-auth-start Edge Function → returns PublicKeyCredentialRequestOptions. 3) Call @simplewebauthn/browser startAuthentication() → triggers biometric. 4) webauthn-auth-finish Edge Function verifies signature against stored public_key, checks counter strictly increments, updates counter and last_used_at, issues Supabase session. On any WebAuthn error (NotAllowedError, cancelled, unsupported), fall back to password form. Device management page in account settings: list webauthn_credentials rows for current user with device_name, created_at, last_used_at; Revoke button deletes the row.
```

Limitations:

- Lovable preview iframe always blocks WebAuthn — every test requires publishing first, which slows iteration
- Lovable Cloud Edge Functions run in Deno; @simplewebauthn/server must be imported via CDN URL (https://esm.sh/@simplewebauthn/server), not npm — the prompt must specify this
- High looping risk: crypto challenge flow has multiple failure modes (encoding errors, expired challenges, counter mismatch) that each need a separate fix iteration

### V0 — fit 4/10, 3–5 hours

Best AI-assisted path: V0 generates cleaner TypeScript for the WebAuthn API routes and handles the server-side crypto logic better than Lovable. Pair with Supabase for credential storage.

1. Prompt V0 with the spec below to generate the WebAuthn registration and authentication API routes and client components
2. Add Supabase environment variables in the V0 Vars panel (NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, SUPABASE_SERVICE_KEY)
3. Run the SQL schema above in your Supabase SQL editor
4. Set RP_ID in Vercel environment variables to your deployed domain (e.g., yourapp.com — not https://yourapp.com)
5. Deploy to Vercel and test WebAuthn on the live HTTPS URL — the V0 sandbox preview does not support WebAuthn

Starter prompt:

```
Build WebAuthn passkey authentication for a Next.js App Router app. Use @simplewebauthn/server in API routes and @simplewebauthn/browser in a client component. API routes needed: 1) POST /api/webauthn/register/start — creates a challenge row in webauthn_challenges table (Supabase, service role key), returns PublicKeyCredentialCreationOptions with rpId from env, user verification: 'required'. 2) POST /api/webauthn/register/finish — verifies with verifyRegistrationResponse(), saves to webauthn_credentials table (credential_id as string, public_key as string, counter as bigint), deletes challenge, returns success. 3) POST /api/webauthn/auth/start — generates challenge, returns PublicKeyCredentialRequestOptions with all credential IDs for the provided userId. 4) POST /api/webauthn/auth/finish — verifies with verifyAuthenticationResponse() against stored public_key and counter, MUST verify counter strictly increases (throw if not), updates counter and last_used_at, creates Supabase session for the user. Client component BiometricLoginButton: check PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable() before rendering; show 'Use password instead' link always; handle NotAllowedError (user cancelled) gracefully by not showing an error; handle InvalidStateError (already registered). Device management panel: /account/security page listing credentials from webauthn_credentials with Revoke button that calls DELETE /api/webauthn/credentials/[id].
```

Limitations:

- V0 does not auto-provision Supabase tables or set the RP_ID environment variable — both require manual setup
- Passkey browser support detection logic (isUserVerifyingPlatformAuthenticatorAvailable) is often absent in first-generation V0 output — verify it is present
- V0 sandbox preview cannot access WebAuthn — deploy to a real domain before testing

### Custom — fit 5/10, 1–2 weeks

Full implementation with @simplewebauthn on both sides, complete counter validation, multi-device policy, and graceful degradation. Required for production-grade passkey security.

1. Install @simplewebauthn/server (Node.js backend) and @simplewebauthn/browser (client); these are the community-standard WebAuthn helpers
2. Implement challenge generation, registration verification, and authentication verification as separate API endpoints with Supabase service-role access
3. Add counter validation that strictly rejects any assertion where counter <= stored counter (indicates cloned authenticator)
4. Build device name detection from navigator.userAgent using a UA parser library and save it at registration time
5. Implement a recovery flow for users who lose all registered devices — usually an email-verified re-enrollment link

Limitations:

- Requires deep familiarity with ArrayBuffer handling, base64url encoding, and the WebAuthn ceremony flow — a single encoding mistake silently breaks all authentication
- Cross-device passkey sync (iCloud Keychain, Google Password Manager) introduces additional flows around authenticator attachment type that need explicit handling

## Gotchas

- **WebAuthn completely blocked in Lovable and V0 preview sandboxes** — navigator.credentials.create() and navigator.credentials.get() throw NotAllowedError in sandboxed iframes and non-HTTPS origins. Both Lovable's preview panel and V0's sandbox environment are iframes — every biometric call fails here regardless of what the code does correctly. This is not a bug in your implementation. Fix: Publish the app to a real HTTPS URL before testing any WebAuthn flow. In Lovable, click Publish and open the live URL. Add a development note in your UI: 'Biometric login only works on the live site' so you don't mistake the preview failure for a code bug.
- **Counter mismatch causes all future biometric logins to fail** — The WebAuthn counter must strictly increase on every successful authentication. If a Supabase Edge Function verifies the signature but then fails mid-write (timeout, network error), the stored counter stays at the old value while the authenticator's internal counter has already incremented. On the next login, the counter check fails and the user is locked out of biometric login on that device. Fix: Wrap the counter update and session creation in a Supabase transaction using a stored procedure or RPC call. If the counter update fails, roll back the session creation. Add a recovery UI in device settings: 'Re-register this device' that deletes the credential and starts fresh enrollment.
- **AI generates base64url encoding incorrectly** — WebAuthn uses base64url encoding (no padding, URL-safe characters) for credential IDs and public keys. Standard base64 uses '+' and '/' which are URL-unsafe. AI tools frequently mix base64 and base64url, or use atob()/btoa() which handles only standard base64. The result is a corrupted credential_id that cannot be matched during authentication. Fix: Always use @simplewebauthn/server and @simplewebauthn/browser helper functions for all encoding and decoding. Never call atob(), btoa(), or Buffer.from(x, 'base64') on WebAuthn credential data. The simplewebauthn libraries handle the base64url encoding consistently on both sides.
- **Passkey button shown on browsers that don't support it** — AI-generated UIs often render the 'Sign in with Face ID' button unconditionally. On browsers without WebAuthn support (older Safari, in-app browsers, Linux without a TPM), clicking it throws NotSupportedError. Users see a raw error or a broken UI with no path forward. Fix: Always call PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable() before rendering the biometric button. Show the standard password login form as the default; surface the biometric option only when the check returns true. Wrap the check in a useEffect with an initial state of 'unsupported' to avoid SSR mismatch.
- **Device name shows as 'Unknown' for all registered credentials** — The WebAuthn registration response does not include the device's friendly name. AI tools leave device_name as null or a generic 'Authenticator' string. In the device management panel, users see a list of identical 'Unknown' entries and cannot tell which device to revoke. Fix: At registration time, read navigator.userAgent on the client and derive a friendly name ('iPhone 15 / Safari', 'Windows 11 / Chrome') using a lightweight parser or a simple regex. Include it in the request body sent to the registration-finish Edge Function alongside the WebAuthn response.

## Best practices

- Always fall back to password login if WebAuthn fails — never make biometric the only path in or you will lock users out
- Validate the counter strictly increments on every assertion — a non-incrementing counter means a cloned authenticator and should trigger immediate credential revocation
- Generate challenges server-side only, expire them in 60 seconds, and delete them immediately after use to prevent replay attacks
- Store credential_id and public_key using @simplewebauthn helper functions to ensure consistent base64url encoding across registration and authentication
- Derive and store device_name at registration time from navigator.userAgent — users cannot manage their devices if every row says 'Unknown'
- Check PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable() before showing the biometric button — hide it completely on unsupported browsers
- Test WebAuthn on both Chrome and Safari before launch — each browser implements parts of the spec differently, especially around cross-device passkeys
- Implement a re-enrollment flow for users who get a new device — deleting the old credential and starting fresh is simpler than cross-device passkey migration

## Frequently asked questions

### What's the difference between passkeys and biometric login?

Passkeys are the underlying credential type — a public/private key pair stored on your device. Biometric login (Face ID, Touch ID) is the mechanism your device uses to authorize using that credential. When you 'log in with Face ID', you are authenticating a passkey, not sending your fingerprint anywhere. The biometric data never leaves the device.

### Does WebAuthn work on iPhones?

Yes, since iOS 16 and Safari 16. iPhone users can create and use passkeys synced through iCloud Keychain, so a passkey registered on their iPhone also works on their Mac and iPad. The synced passkeys experience requires iOS 16+ on Apple devices; Android supports passkeys via Google Password Manager from Android 9+.

### Can users still use a password if biometrics fail?

Yes, and they must be able to. Always show a 'Use password instead' link alongside the biometric button. WebAuthn can fail for many reasons — new device, browser not supported, user cancelled the OS dialog — and none of those should lock the user out of their account. Biometric login should be an enhancement, not a replacement for password auth.

### Is biometric data stored on my server?

No. The biometric sensor output never leaves the user's device. What your server stores is a public key — like a lock that only the user's device (holding the private key) can open. Even if your database is fully compromised, an attacker cannot use the stored public key to impersonate users.

### Which browsers support passkeys?

Chrome 108+, Safari 16+, and Edge 109+ all support passkeys natively. Firefox has partial support (WebAuthn works, but cross-device passkey sync is limited). In-app browsers (Instagram, TikTok, Facebook) typically do not support WebAuthn. Always check PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable() before showing the biometric button.

### How do I let users manage their registered devices?

Build a device management panel in account settings that reads the webauthn_credentials table (scoped by RLS to the current user). Show credential_id (or a friendly label), device_name, created_at, and last_used_at for each row. A Revoke button deletes the row. After revocation, the device can no longer sign in with biometrics until the user re-registers it.

### Is WebAuthn the same as FIDO2?

WebAuthn is the browser API specification. FIDO2 is the umbrella standard from the FIDO Alliance that WebAuthn implements, along with CTAP2 (the protocol for hardware security keys). For web app development, you interact with WebAuthn — the FIDO2 and CTAP2 layers are handled by the browser and OS.

### What happens if a user gets a new phone?

If their passkeys are synced via iCloud Keychain or Google Password Manager, they automatically transfer to the new phone — no action needed. If they used a non-synced passkey (a hardware key, or a phone without cloud sync enabled), they need to revoke the old credential in device settings and register the new device. This is why keeping password login as a fallback is essential.

---

Source: https://www.rapidevelopers.com/app-features/biometric-login
© RapidDev — https://www.rapidevelopers.com/app-features/biometric-login
