Passkeys in Production: Adding WebAuthn to a SaaS
Passwordless authentication with Face ID, Touch ID, and security keys
Passwords are a liability. Users reuse them. They get phished. They forget them and reset them constantly.
Passkeys are the future: cryptographic credentials stored on your device, unlocked with biometrics. No password to steal. No password to forget.
Here's how we added passkey support to Boop.
What Are Passkeys?
Passkeys use the WebAuthn standard. Instead of a password, your device stores a cryptographic key pair:
- Private key: Stays on your device, never leaves
- Public key: Stored on the server
To log in, your device signs a challenge with the private key. The server verifies the signature with the public key. No shared secrets.
βββββββββββββββ βββββββββββββββ
β Device β β Server β
β β β β
β Private ββββΌββ Signs challenge βββΆβ Public β
β Key β β Key β
β ββββ Verified! βββββββββΌβ β
βββββββββββββββ βββββββββββββββ
The Stack
We use:
- @simplewebauthn/browser - Client-side WebAuthn API wrapper
- @simplewebauthn/server - Server-side verification
- Redis - Temporary challenge storage
- Prisma - Passkey credential storage
Registration Flow
Step 1: Generate Registration Options
// GET /api/passkey/register/options
export async function GET() {
const session = await getServerSession(authOptions);
const user = await prisma.user.findUnique({
where: { email: session.user.email },
include: { passkeys: true },
});
const options = await generateRegistrationOptions({
rpName: "Boop.one",
rpID: new URL(process.env.NEXTAUTH_URL).hostname,
userID: new TextEncoder().encode(user.id),
userName: user.email,
userDisplayName: user.name || user.email,
// Don't require attestation
attestationType: "none",
// Prevent re-registering existing passkeys
excludeCredentials: user.passkeys.map((pk) => ({
id: pk.credentialId,
transports: pk.transports,
})),
// Prefer platform authenticators (Face ID, Touch ID)
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
},
});
// Store challenge for verification
await storeWebAuthnChallenge(user.id, options.challenge);
return NextResponse.json(options);
}
Step 2: Browser Creates Credential
// Client-side
import { startRegistration } from "@simplewebauthn/browser";
const optionsRes = await fetch("/api/passkey/register/options");
const options = await optionsRes.json();
// This triggers Face ID / Touch ID / Security Key prompt
const credential = await startRegistration(options);
// Send credential to server
await fetch("/api/passkey/register/verify", {
method: "POST",
body: JSON.stringify({ credential, name: "MacBook Pro" }),
});
Step 3: Verify and Store
// POST /api/passkey/register/verify
export async function POST(request: Request) {
const { credential, name } = await request.json();
// Retrieve the challenge from Redis
const expectedChallenge = await getAndDeleteWebAuthnChallenge(user.id);
// Verify the registration
const verification = await verifyRegistrationResponse({
response: credential,
expectedChallenge,
expectedOrigin: process.env.NEXTAUTH_URL,
expectedRPID: rpID,
});
if (!verification.verified) {
return NextResponse.json({ error: "Verification failed" }, { status: 400 });
}
// Store the passkey
await prisma.passkey.create({
data: {
userId: user.id,
credentialId: credential.id,
publicKey: Buffer.from(verification.registrationInfo.credential.publicKey)
.toString("base64url"),
counter: verification.registrationInfo.credential.counter,
deviceType: verification.registrationInfo.credentialDeviceType,
backedUp: verification.registrationInfo.credentialBackedUp,
transports: credential.response.transports || [],
name: name || null,
},
});
return NextResponse.json({ success: true });
}
Database Schema
model Passkey {
id String @id @default(cuid())
userId String
credentialId String @unique
publicKey String // Base64url encoded
counter Int // Replay attack protection
deviceType String // "singleDevice" or "multiDevice"
backedUp Boolean // Is credential backed up (iCloud, etc)?
transports String[] // ["internal", "usb", "ble", "nfc"]
name String? // User-friendly name
createdAt DateTime @default(now())
lastUsedAt DateTime?
user User @relation(fields: [userId], references: [id])
}
Challenge Storage in Redis
Challenges are one-time use with a 5-minute TTL:
export async function storeWebAuthnChallenge(
userId: string,
challenge: string
): Promise<void> {
await redis.set(
`webauthn:challenge:${userId}`,
challenge,
"EX", 300 // 5 minute expiry
);
}
export async function getAndDeleteWebAuthnChallenge(
userId: string
): Promise<string | null> {
const key = `webauthn:challenge:${userId}`;
const challenge = await redis.get(key);
if (challenge) {
await redis.del(key); // One-time use
}
return challenge;
}
Authentication Flow
Similar to registration, but uses startAuthentication instead:
// Client
const options = await fetch("/api/passkey/auth/options").then(r => r.json());
const assertion = await startAuthentication(options);
await fetch("/api/passkey/auth/verify", {
method: "POST",
body: JSON.stringify({ assertion }),
});
Counter Verification (Replay Protection)
Each passkey has a counter that increments with every use. If we see a lower counter than expected, the credential may have been cloned:
// During authentication verification
if (verification.authenticationInfo.newCounter <= storedCounter) {
// Possible credential cloning attack!
throw new Error("Counter too low - possible replay attack");
}
// Update stored counter
await prisma.passkey.update({
where: { id: passkey.id },
data: { counter: verification.authenticationInfo.newCounter },
});
User Experience
Managing Passkeys
Users can register multiple passkeys (one per device) and name them:
{passkeys.map((passkey) => (
<div key={passkey.id}>
<span>{passkey.deviceType === "platform" ? "π±" : "π"}</span>
<span>{passkey.name || "Unnamed Passkey"}</span>
<span>Added {formatDate(passkey.createdAt)}</span>
<button onClick={() => deletePasskey(passkey.id)}>Remove</button>
</div>
))}
Device Type Icons
- π± Platform authenticator (Face ID, Touch ID, Windows Hello)
- π Cross-platform (USB security key, NFC)
Tracking Last Used
Update lastUsedAt on every successful authentication:
await prisma.passkey.update({
where: { id: passkey.id },
data: { lastUsedAt: new Date() },
});
Security Considerations
1. Origin Validation
Always verify the origin matches your domain:
expectedOrigin: process.env.NEXTAUTH_URL,
expectedRPID: new URL(process.env.NEXTAUTH_URL).hostname,
2. No Attestation Required
We use attestationType: "none" because:
- Attestation adds friction
- We don't need to verify the authenticator model
- Privacy: attestation can fingerprint devices
3. Challenge Expiry
Challenges expire after 5 minutes and are single-use (delete after retrieval).
4. Multiple Passkeys
Users should register passkeys on multiple devices. If they lose their phone, they can still log in with their laptop.
Fallback: Traditional Auth
Passkeys are additive. Users can still log in with email/password if they haven't set up passkeys. We show passkey login first if they have any registered:
const hasPasskeys = user.passkeys.length > 0;
if (hasPasskeys) {
// Show "Sign in with Passkey" button first
} else {
// Show email/password form
}
The Results
Since adding passkeys:
- 50% of active users have registered at least one passkey
- Zero password-related support tickets from passkey users
- Login is faster (biometric vs typing password)
- Phishing-proof (passkeys are origin-bound)
Lessons Learned
Use a library - Don't implement WebAuthn from scratch. SimpleWebAuthn handles the edge cases.
Store challenges in Redis - They need to be ephemeral and accessible across requests
Track device type - Users want to know which passkeys are on which devices
Allow multiple passkeys - One per device is the expected pattern
Keep password fallback - Not everyone has a compatible device
Passkeys are the best authentication UX that also happens to be the most secure. Your users just need to look at their phone or touch their laptop.
Passwordless authentication at Boop
Top comments (0)