Feature spec
IntermediateCategory
auth-security
Build with AI
3–5 hours with Lovable or V0
Custom build
1–2 weeks custom dev
Running cost
$0–$25/mo (Supabase credential storage; WebAuthn is browser-native)
Works on
Everything it takes to ship Biometric Login — parts, prompts, and real costs.
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.
What users consider table stakes in 2026
- Register biometric credential on first login with a single tap — no form to fill out after the initial account creation
- Skip the password field on return visits — the biometric prompt replaces it entirely
- Clear fallback to password login if biometric fails, the device is not enrolled, or the browser doesn't support WebAuthn
- Works on Chrome 108+, Safari 16+, and Edge 109+ — show a compatibility message on unsupported browsers instead of a broken button
- Device management panel in account settings listing registered authenticators with name and registration date
- Revoke individual devices from the device list without requiring a password reset
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
Backendnavigator.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.
Note: WebAuthn is completely blocked in iframes and sandboxed origins — this means Lovable preview panels and V0 sandbox previews will always throw NotAllowedError. Testing requires a deployed HTTPS URL.
Challenge generator
BackendA 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.
Note: Challenges must be server-generated, never client-generated. A predictable challenge allows replay attacks where a recorded biometric assertion is reused to authenticate.
Credential storage
DataA 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.
Note: Store credential_id and public_key as base64url-encoded text if your DB does not have a bytea type. Use @simplewebauthn/server helper functions for all encoding — never convert manually.
Biometric prompt UI
UIA 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.
Note: Do not show the biometric button at all before checking platform authenticator availability. A button that throws NotAllowedError on click confuses users who don't understand WebAuthn.
Device management panel
UIAn 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.
Note: Derive device_name from navigator.userAgent on the client during registration and pass it to the server — the WebAuthn API itself does not expose a friendly device name.
Fallback auth flow
UIA '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.
Note: Never leave a user locked out because biometrics failed. The fallback is not optional — it is a hard requirement for accessible, production-quality passkey implementations.
The data model
Two tables are needed: one for WebAuthn credentials, one for single-use challenges. Run in the Supabase SQL editor:
1create table public.webauthn_credentials (2 id uuid primary key default gen_random_uuid(),3 user_id uuid references auth.users(id) on delete cascade not null,4 credential_id text unique not null,5 public_key text not null,6 counter bigint not null default 0,7 device_name text,8 created_at timestamptz not null default now(),9 last_used_at timestamptz10);1112alter table public.webauthn_credentials enable row level security;1314create policy "Users can view own credentials"15 on public.webauthn_credentials for select16 using (auth.uid() = user_id);1718create policy "Users can delete own credentials"19 on public.webauthn_credentials for delete20 using (auth.uid() = user_id);2122-- Insert and counter updates go through Edge Functions using service role key2324create table public.webauthn_challenges (25 id uuid primary key default gen_random_uuid(),26 user_id uuid references auth.users(id) on delete cascade not null,27 challenge text not null,28 expires_at timestamptz not null default (now() + interval '60 seconds'),29 created_at timestamptz not null default now()30);3132alter table public.webauthn_challenges enable row level security;33-- No client SELECT on challenges; all reads via Edge Function with service role3435create index webauthn_creds_user_idx on public.webauthn_credentials (user_id);36create index webauthn_challenges_expiry_idx on public.webauthn_challenges (expires_at);Heads up: 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 it — pick your path
Each tool fits this feature differently. Switch tabs to compare fit, time, the exact steps, and a copy-paste prompt.
Full implementation with @simplewebauthn on both sides, complete counter validation, multi-device policy, and graceful degradation. Required for production-grade passkey security.
Step by step
- 1Install @simplewebauthn/server (Node.js backend) and @simplewebauthn/browser (client); these are the community-standard WebAuthn helpers
- 2Implement challenge generation, registration verification, and authentication verification as separate API endpoints with Supabase service-role access
- 3Add counter validation that strictly rejects any assertion where counter <= stored counter (indicates cloned authenticator)
- 4Build device name detection from navigator.userAgent using a UA parser library and save it at registration time
- 5Implement a recovery flow for users who lose all registered devices — usually an email-verified re-enrollment link
Where this path bites
- 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
Third-party services you'll need
WebAuthn is browser-native — no third-party biometric service needed. Only your credential storage and helper libraries:
| Service | What it does | Free tier | Paid from |
|---|---|---|---|
| @simplewebauthn/browser + @simplewebauthn/server | WebAuthn ceremony helpers — handle base64url encoding, options formatting, and response verification | Open-source (MIT), free | Free |
| Supabase | Credential storage (webauthn_credentials table), challenge table, Edge Functions for server-side verification | Free tier sufficient for credential storage; 500MB DB, 2 projects | $25/mo Pro for production uptime SLA and higher Edge Function quota |
Swipe the table sideways to see pricing.
What it costs to run
Drag through the tiers to see how your monthly bill scales with users — no surprises later.
Estimated monthly running cost
WebAuthn is browser-native with zero service cost. Supabase free tier handles credential storage easily — 100 users with 2–3 devices each is well under 500MB.
Estimates use verified 2026 pricing and assume typical usage per user. Your real bill depends on activity, storage, and third-party plans.
What breaks when AI tools build this
The failures people actually hit on their first build — the symptom, why it happens, and the exact fix.
WebAuthn completely blocked in Lovable and V0 preview sandboxes
Symptom: 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
Symptom: 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
Symptom: 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
Symptom: 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
Symptom: 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
When You Need Custom Development
AI tools can scaffold a working WebAuthn passkey implementation with detailed prompts. Custom development is needed when your requirements go beyond single-device biometric login:
- You need hardware security key (YubiKey) support alongside platform authenticators — requires handling non-platform authenticator attachment type and different verification flows
- You require attestation verification for high-security environments (banking, healthcare) where you need to verify the authenticator manufacturer's certificate chain
- You need cross-device passkey sync policy control — enterprise environments may prohibit iCloud Keychain or Google Password Manager synced passkeys
- You have FIDO2 Level 2 certification requirements where your passkey implementation itself must be audited and certified
RapidDev builds these features for production
Real auth, database, and payments — fixed price, delivered in weeks. You own 100% of the code.
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.
Need this feature production-ready?
RapidDev builds biometric login into real apps — auth, database, payments — at $13K–$25K.
Book a free consultation30-min call. No commitment.