DEV Community

boop dev
boop dev

Posted on

Passkeys in Production: Adding WebAuthn to a SaaS

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! ────────┼─            β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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" }),
});
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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])
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 }),
});
Enter fullscreen mode Exit fullscreen mode

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 },
});
Enter fullscreen mode Exit fullscreen mode

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>
))}
Enter fullscreen mode Exit fullscreen mode

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() },
});
Enter fullscreen mode Exit fullscreen mode

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,
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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

  1. Use a library - Don't implement WebAuthn from scratch. SimpleWebAuthn handles the edge cases.

  2. Store challenges in Redis - They need to be ephemeral and accessible across requests

  3. Track device type - Users want to know which passkeys are on which devices

  4. Allow multiple passkeys - One per device is the expected pattern

  5. 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)