Passkeys (WebAuthn)
Passkeys use public-key cryptography to authenticate users without passwords. The browser creates a key pair during registration — the private key stays on the device (or synced via iCloud/Google), and the public key is stored in your database. During login, the server sends a challenge, the browser signs it with the private key, and the server verifies the signature with the stored public key.
ideal-auth handles the session after authentication. The WebAuthn protocol (challenge generation, credential storage, verification) is handled by @simplewebauthn/server and @simplewebauthn/browser.
Installation
Section titled “Installation”bun add ideal-auth @simplewebauthn/server @simplewebauthn/browserDatabase schema
Section titled “Database schema”Store passkey credentials alongside your users. Each user can have multiple passkeys (e.g., phone + laptop + security key).
CREATE TABLE passkeys ( id TEXT PRIMARY KEY, -- credential ID (base64url) user_id TEXT NOT NULL REFERENCES users(id), public_key BYTEA NOT NULL, -- public key bytes counter BIGINT NOT NULL DEFAULT 0, -- signature counter (replay protection) device_type TEXT, -- 'singleDevice' or 'multiDevice' backed_up BOOLEAN NOT NULL DEFAULT FALSE, transports TEXT[], -- e.g., ['usb', 'ble', 'nfc', 'internal'] created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW());
CREATE INDEX idx_passkeys_user_id ON passkeys (user_id);import { pgTable, text, bigint, boolean, timestamp } from 'drizzle-orm/pg-core';
export const passkeys = pgTable('passkeys', { id: text('id').primaryKey(), userId: text('user_id').notNull().references(() => users.id), publicKey: text('public_key').notNull(), // base64url-encoded counter: bigint('counter', { mode: 'number' }).notNull().default(0), deviceType: text('device_type'), backedUp: boolean('backed_up').notNull().default(false), transports: text('transports'), // JSON string array createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),});model Passkey { id String @id userId String @map("user_id") publicKey Bytes @map("public_key") counter BigInt @default(0) deviceType String? @map("device_type") backedUp Boolean @default(false) @map("backed_up") transports String[] createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id])
@@map("passkeys")}Auth setup
Section titled “Auth setup”Passkey authentication doesn’t need hash or resolveUserByCredentials. Use resolveUser or sessionFields depending on whether you have a database.
import { createAuth } from 'ideal-auth';
type User = { id: string; email: string; name: string };
export const auth = createAuth<User>({ secret: process.env.IDEAL_AUTH_SECRET!, cookie: createCookieBridge(), resolveUser: async (id) => db.user.findUnique({ where: { id } }),});import { createAuth } from 'ideal-auth';
type User = { id: string; email: string; name: string };
export const auth = createAuth<User>({ secret: process.env.IDEAL_AUTH_SECRET!, cookie: createCookieBridge(), sessionFields: ['email', 'name'],});Relying party config
Section titled “Relying party config”Create a shared configuration for the WebAuthn relying party (your application):
import type { GenerateRegistrationOptionsOpts, GenerateAuthenticationOptionsOpts,} from '@simplewebauthn/server';
export const rpName = 'My App';export const rpID = process.env.WEBAUTHN_RP_ID!; // e.g., 'example.com'export const origin = process.env.WEBAUTHN_ORIGIN!; // e.g., 'https://example.com'WEBAUTHN_RP_ID="example.com"WEBAUTHN_ORIGIN="https://example.com"Registration
Section titled “Registration”Registration is a two-step process: generate options (server), create credential (browser), verify and store (server).
Step 1: Generate registration options
Section titled “Step 1: Generate registration options”import { generateRegistrationOptions } from '@simplewebauthn/server';import { auth } from '@/lib/auth';import { rpName, rpID } from '@/lib/webauthn';import { db } from '@/lib/db';import { NextResponse } from 'next/server';
export async function POST() { const session = auth(); const user = await session.user();
if (!user) { return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); }
// Get existing passkeys for this user (to exclude during registration) const existingPasskeys = await db.passkey.findMany({ where: { userId: user.id }, });
const options = await generateRegistrationOptions({ rpName, rpID, userName: user.email, userDisplayName: user.name, excludeCredentials: existingPasskeys.map((pk) => ({ id: pk.id, transports: pk.transports, })), authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred', }, });
// Store the challenge for verification (short-lived) // Use a cookie, session store, or cache — must be available in the verify step const cookieStore = await (await import('next/headers')).cookies(); cookieStore.set('webauthn_challenge', options.challenge, { httpOnly: true, secure: process.env.NODE_ENV === 'production', maxAge: 300, // 5 minutes path: '/', });
return NextResponse.json(options);}import { generateRegistrationOptions } from '@simplewebauthn/server';import { auth } from '../lib/auth';import { rpName, rpID } from '../lib/webauthn';import { db } from '../lib/db';import { Router } from 'express';
const router = Router();
router.post('/passkey/register/options', async (req, res) => { const session = auth(req, res); const user = await session.user();
if (!user) { return res.status(401).json({ error: 'Not authenticated' }); }
const existingPasskeys = await db.passkey.findMany({ where: { userId: user.id }, });
const options = await generateRegistrationOptions({ rpName, rpID, userName: user.email, userDisplayName: user.name, excludeCredentials: existingPasskeys.map((pk) => ({ id: pk.id, transports: pk.transports, })), authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred', }, });
// Store challenge in session or cookie req.session.webauthnChallenge = options.challenge;
res.json(options);});
export default router;Step 2: Create credential (browser)
Section titled “Step 2: Create credential (browser)”import { startRegistration, startAuthentication } from '@simplewebauthn/browser';
export async function registerPasskey() { // 1. Get options from server const optionsRes = await fetch('/api/auth/passkey/register/options', { method: 'POST', }); const options = await optionsRes.json();
// 2. Browser creates the credential (triggers biometric/PIN prompt) const credential = await startRegistration({ optionsJSON: options });
// 3. Send credential to server for verification const verifyRes = await fetch('/api/auth/passkey/register/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credential), });
return verifyRes.json();}Step 3: Verify and store credential
Section titled “Step 3: Verify and store credential”import { verifyRegistrationResponse } from '@simplewebauthn/server';import { auth } from '@/lib/auth';import { rpID, origin } from '@/lib/webauthn';import { db } from '@/lib/db';import { cookies } from 'next/headers';import { NextResponse } from 'next/server';
export async function POST(request: Request) { const session = auth(); const user = await session.user();
if (!user) { return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); }
const body = await request.json(); const cookieStore = await cookies(); const expectedChallenge = cookieStore.get('webauthn_challenge')?.value;
if (!expectedChallenge) { return NextResponse.json({ error: 'Challenge expired' }, { status: 400 }); }
const verification = await verifyRegistrationResponse({ response: body, expectedChallenge, expectedOrigin: origin, expectedRPID: rpID, });
if (!verification.verified || !verification.registrationInfo) { return NextResponse.json({ error: 'Verification failed' }, { status: 400 }); }
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
// Store the passkey await db.passkey.create({ data: { id: credential.id, userId: user.id, publicKey: Buffer.from(credential.publicKey).toString('base64url'), counter: Number(credential.counter), deviceType: credentialDeviceType, backedUp: credentialBackedUp, transports: body.response.transports ?? [], }, });
// Clean up challenge cookieStore.delete('webauthn_challenge');
return NextResponse.json({ success: true });}import { verifyRegistrationResponse } from '@simplewebauthn/server';import { rpID, origin } from '../lib/webauthn';
router.post('/passkey/register/verify', async (req, res) => { const session = auth(req, res); const user = await session.user();
if (!user) { return res.status(401).json({ error: 'Not authenticated' }); }
const expectedChallenge = req.session.webauthnChallenge; if (!expectedChallenge) { return res.status(400).json({ error: 'Challenge expired' }); }
const verification = await verifyRegistrationResponse({ response: req.body, expectedChallenge, expectedOrigin: origin, expectedRPID: rpID, });
if (!verification.verified || !verification.registrationInfo) { return res.status(400).json({ error: 'Verification failed' }); }
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
await db.passkey.create({ data: { id: credential.id, userId: user.id, publicKey: Buffer.from(credential.publicKey).toString('base64url'), counter: Number(credential.counter), deviceType: credentialDeviceType, backedUp: credentialBackedUp, transports: req.body.response.transports ?? [], }, });
delete req.session.webauthnChallenge;
res.json({ success: true });});Authentication
Section titled “Authentication”Authentication is also two steps: generate options (server), get assertion (browser), verify and login (server).
Step 1: Generate authentication options
Section titled “Step 1: Generate authentication options”import { generateAuthenticationOptions } from '@simplewebauthn/server';import { rpID } from '@/lib/webauthn';import { cookies } from 'next/headers';import { NextResponse } from 'next/server';
export async function POST() { const options = await generateAuthenticationOptions({ rpID, userVerification: 'preferred', });
const cookieStore = await cookies(); cookieStore.set('webauthn_challenge', options.challenge, { httpOnly: true, secure: process.env.NODE_ENV === 'production', maxAge: 300, path: '/', });
return NextResponse.json(options);}import { generateAuthenticationOptions } from '@simplewebauthn/server';
router.post('/passkey/login/options', async (req, res) => { const options = await generateAuthenticationOptions({ rpID, userVerification: 'preferred', });
req.session.webauthnChallenge = options.challenge;
res.json(options);});Step 2: Get assertion (browser)
Section titled “Step 2: Get assertion (browser)”export async function loginWithPasskey() { // 1. Get options from server const optionsRes = await fetch('/api/auth/passkey/login/options', { method: 'POST', }); const options = await optionsRes.json();
// 2. Browser signs the challenge (triggers biometric/PIN prompt) const credential = await startAuthentication({ optionsJSON: options });
// 3. Send assertion to server for verification const verifyRes = await fetch('/api/auth/passkey/login/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credential), });
return verifyRes.json();}Step 3: Verify assertion and create session
Section titled “Step 3: Verify assertion and create session”This is where ideal-auth comes in — after WebAuthn verification, call auth().login(user) to create the session.
import { verifyAuthenticationResponse } from '@simplewebauthn/server';import { auth } from '@/lib/auth';import { rpID, origin } from '@/lib/webauthn';import { db } from '@/lib/db';import { cookies } from 'next/headers';import { NextResponse } from 'next/server';
export async function POST(request: Request) { const body = await request.json(); const cookieStore = await cookies(); const expectedChallenge = cookieStore.get('webauthn_challenge')?.value;
if (!expectedChallenge) { return NextResponse.json({ error: 'Challenge expired' }, { status: 400 }); }
// Look up the passkey const passkey = await db.passkey.findUnique({ where: { id: body.id }, });
if (!passkey) { return NextResponse.json({ error: 'Passkey not found' }, { status: 400 }); }
const verification = await verifyAuthenticationResponse({ response: body, expectedChallenge, expectedOrigin: origin, expectedRPID: rpID, credential: { id: passkey.id, publicKey: Buffer.from(passkey.publicKey, 'base64url'), counter: passkey.counter, transports: passkey.transports, }, });
if (!verification.verified) { return NextResponse.json({ error: 'Verification failed' }, { status: 400 }); }
// Update the signature counter (replay protection) await db.passkey.update({ where: { id: passkey.id }, data: { counter: Number(verification.authenticationInfo.newCounter) }, });
// Look up the user and create a session — this is where ideal-auth comes in const user = await db.user.findUnique({ where: { id: passkey.userId }, });
if (!user) { return NextResponse.json({ error: 'User not found' }, { status: 400 }); }
const session = auth(); await session.login(user);
// Clean up challenge cookieStore.delete('webauthn_challenge');
return NextResponse.json({ success: true });}import { verifyAuthenticationResponse } from '@simplewebauthn/server';
router.post('/passkey/login/verify', async (req, res) => { const expectedChallenge = req.session.webauthnChallenge; if (!expectedChallenge) { return res.status(400).json({ error: 'Challenge expired' }); }
const passkey = await db.passkey.findUnique({ where: { id: req.body.id }, });
if (!passkey) { return res.status(400).json({ error: 'Passkey not found' }); }
const verification = await verifyAuthenticationResponse({ response: req.body, expectedChallenge, expectedOrigin: origin, expectedRPID: rpID, credential: { id: passkey.id, publicKey: Buffer.from(passkey.publicKey, 'base64url'), counter: passkey.counter, transports: passkey.transports, }, });
if (!verification.verified) { return res.status(400).json({ error: 'Verification failed' }); }
await db.passkey.update({ where: { id: passkey.id }, data: { counter: Number(verification.authenticationInfo.newCounter) }, });
const user = await db.user.findUnique({ where: { id: passkey.userId }, });
if (!user) { return res.status(400).json({ error: 'User not found' }); }
// Create session with ideal-auth const session = auth(req, res); await session.login(user);
delete req.session.webauthnChallenge;
res.json({ success: true });});Login form
Section titled “Login form”Offer passkey login alongside traditional email/password:
'use client';
import { useState } from 'react';import { loginWithPasskey } from '@/lib/passkey-client';import { useRouter } from 'next/navigation';
export default function LoginPage() { const router = useRouter(); const [error, setError] = useState('');
async function handlePasskeyLogin() { setError(''); try { const result = await loginWithPasskey(); if (result.success) { router.push('/dashboard'); } else { setError(result.error ?? 'Passkey login failed.'); } } catch { setError('Passkey authentication was cancelled or not available.'); } }
return ( <div> <h1>Sign in</h1>
{error && <p className="error">{error}</p>}
{/* Passkey login */} <button type="button" onClick={handlePasskeyLogin}> Sign in with passkey </button>
<hr />
{/* Traditional login form */} <form action={loginAction}> <input name="email" type="email" required /> <input name="password" type="password" required /> <button type="submit">Sign in with email</button> </form> </div> );}Passkey management
Section titled “Passkey management”Let authenticated users register new passkeys and remove existing ones:
'use client';
import { useState, useEffect } from 'react';import { registerPasskey } from '@/lib/passkey-client';
export default function PasskeySettings() { const [passkeys, setPasskeys] = useState([]); const [message, setMessage] = useState('');
useEffect(() => { fetch('/api/auth/passkey/list') .then((r) => r.json()) .then((data) => setPasskeys(data.passkeys)); }, []);
async function handleRegister() { try { const result = await registerPasskey(); if (result.success) { setMessage('Passkey registered successfully.'); // Refresh the list const data = await fetch('/api/auth/passkey/list').then((r) => r.json()); setPasskeys(data.passkeys); } } catch { setMessage('Registration was cancelled.'); } }
async function handleRemove(id: string) { await fetch(`/api/auth/passkey/${id}`, { method: 'DELETE' }); setPasskeys(passkeys.filter((pk: any) => pk.id !== id)); }
return ( <div> <h2>Passkeys</h2> {message && <p>{message}</p>}
<ul> {passkeys.map((pk: any) => ( <li key={pk.id}> {pk.deviceType === 'multiDevice' ? 'Synced passkey' : 'Device-bound passkey'} {' — '} {new Date(pk.createdAt).toLocaleDateString()} <button onClick={() => handleRemove(pk.id)}>Remove</button> </li> ))} </ul>
<button onClick={handleRegister}>Register new passkey</button> </div> );}Passkeys + 2FA
Section titled “Passkeys + 2FA”Passkeys can replace passwords entirely, but you may want to support both during a transition period. Common patterns:
Passkey-only accounts: Users who register with a passkey never set a password. The password field in your database is nullable.
Passkey + password: Users have both options. The login page offers passkey first, with a fallback to email/password.
Passkey replaces 2FA: Passkeys with userVerification: 'required' include a biometric or PIN check, which is functionally equivalent to 2FA. You can skip TOTP for users who authenticate via passkey.
// After passkey login, check if user verification was performedif (verification.authenticationInfo.userVerified) { // Passkey included biometric/PIN — no additional 2FA needed await session.login(user);} else { // Passkey was device-only — require TOTP as additional factor // Redirect to 2FA verification page}Security considerations
Section titled “Security considerations”- Challenge expiry: Challenges should expire within 5 minutes. Store them in a short-lived httpOnly cookie or server-side session — never in the URL or client-side storage.
- Signature counter: Always update the counter after successful authentication. If a passkey’s counter goes backwards, it may indicate a cloned credential — reject the authentication.
- rpID scope: Set
rpIDto your root domain (e.g.,example.com), not a subdomain. This allows passkeys to work across subdomains (app.example.com,admin.example.com). - User verification: Use
userVerification: 'preferred'for most apps. Use'required'for high-security flows (e.g., banking).'discouraged'skips biometric/PIN — only use for low-security scenarios. - Transports: Store the credential’s
transportsarray and pass it back during authentication. This helps the browser find the right authenticator (USB, BLE, NFC, or internal). - Rate limiting: Rate limit the authentication options and verify endpoints to prevent abuse. Use
createRateLimiterfrom ideal-auth.