hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Two-Factor and Passwordless Auth with @hectoday/http

Why Passwords Are Not Enough

  • The Problem with Passwords
  • Project Setup

TOTP (Time-Based One-Time Passwords)

  • How TOTP Works
  • Generating Secrets and QR Codes
  • Enabling 2FA on an Account
  • Verifying TOTP on Login
  • Time Windows and Clock Drift

Recovery

  • Recovery Codes
  • Disabling 2FA
  • Account Recovery When Everything Is Lost

Magic Links

  • How Magic Links Work
  • Building Magic Link Login
  • Security Considerations

WebAuthn and Passkeys

  • What Are Passkeys?
  • Registration Flow
  • Authentication Flow
  • Passkeys as Second Factor or Primary

Putting It All Together

  • Multi-Method Auth
  • Auth Method Checklist and Capstone

Registration Flow

The server side

WebAuthn registration has two server endpoints: one to generate options (the challenge), one to verify the response (the credential).

// src/routes/passkeys.ts
import { route, group } from "@hectoday/http";
import { generateRegistrationOptions, verifyRegistrationResponse } from "@simplewebauthn/server";
import db from "../db.js";
import { authenticate } from "../auth.js";

const RP_NAME = "Hectoday Course";
const RP_ID = "localhost";
const ORIGIN = "http://localhost:3000";

export const passkeyRoutes = group([
  // Step 1: Generate registration options
  route.post("/me/passkeys/register/options", {
    resolve: async (c) => {
      const user = authenticate(c.request);
      if (user instanceof Response) return user;

      // Get existing passkeys for this user (to exclude them)
      const existing = db
        .prepare("SELECT credential_id FROM passkeys WHERE user_id = ?")
        .all(user.id) as { credential_id: string }[];

      const options = await generateRegistrationOptions({
        rpName: RP_NAME,
        rpID: RP_ID,
        userName: user.email,
        userDisplayName: user.name,
        excludeCredentials: existing.map((p) => ({
          id: p.credential_id,
        })),
        authenticatorSelection: {
          residentKey: "preferred",
          userVerification: "preferred",
        },
      });

      // Store the challenge temporarily
      db.prepare(
        "INSERT OR REPLACE INTO webauthn_challenges (user_id, challenge, expires_at) VALUES (?, ?, ?)",
      ).run(user.id, options.challenge, Date.now() + 5 * 60 * 1000);

      return Response.json(options);
    },
  }),

  // Step 2: Verify registration response
  route.post("/me/passkeys/register/verify", {
    resolve: async (c) => {
      const user = authenticate(c.request);
      if (user instanceof Response) return user;

      const body = await c.request.json();

      // Retrieve the stored challenge
      const challengeRow = db
        .prepare("SELECT challenge, expires_at FROM webauthn_challenges WHERE user_id = ?")
        .get(user.id) as { challenge: string; expires_at: number } | undefined;

      if (!challengeRow || Date.now() > challengeRow.expires_at) {
        return Response.json({ error: "Challenge expired" }, { status: 400 });
      }

      try {
        const verification = await verifyRegistrationResponse({
          response: body,
          expectedChallenge: challengeRow.challenge,
          expectedOrigin: ORIGIN,
          expectedRPID: RP_ID,
        });

        if (!verification.verified || !verification.registrationInfo) {
          return Response.json({ error: "Verification failed" }, { status: 400 });
        }

        const { credential, credentialDeviceType } = verification.registrationInfo;

        // Store the credential
        const id = crypto.randomUUID();
        db.prepare(
          "INSERT INTO passkeys (id, user_id, credential_id, public_key, counter, name) VALUES (?, ?, ?, ?, ?, ?)",
        ).run(
          id,
          user.id,
          Buffer.from(credential.id).toString("base64url"),
          Buffer.from(credential.publicKey).toString("base64url"),
          credential.counter,
          body.name ?? `Passkey (${credentialDeviceType})`,
        );

        // Clean up the challenge
        db.prepare("DELETE FROM webauthn_challenges WHERE user_id = ?").run(user.id);

        return Response.json({
          message: "Passkey registered successfully.",
          id,
        });
      } catch (error) {
        return Response.json({ error: "Verification failed" }, { status: 400 });
      }
    },
  }),
]);

What each part does

generateRegistrationOptions: Creates the challenge and parameters the browser needs to create a credential. excludeCredentials prevents the user from registering the same authenticator twice. residentKey: "preferred" requests a discoverable credential (passkey), but falls back to a non-discoverable one if the authenticator does not support it.

The challenge: A random value stored temporarily on the server. The browser includes it in the credential, and the server verifies it to prevent replay attacks. Challenges expire after 5 minutes.

verifyRegistrationResponse: Validates the browser’s response: checks the challenge matches, the origin matches (localhost in development), and the attestation is valid. Returns the public key and credential ID.

Storing the credential: The public key and credential ID are stored in the passkeys table. The counter starts at 0 and increments with each use (used to detect cloned authenticators).

The client side

The browser code calls the WebAuthn API:

// Client-side JavaScript

// Step 1: Get options from the server
const optionsRes = await fetch("/me/passkeys/register/options", { method: "POST" });
const options = await optionsRes.json();

// Step 2: Create the credential (triggers biometric prompt)
const credential = await navigator.credentials.create({ publicKey: options });

// Step 3: Send the response to the server
const verifyRes = await fetch("/me/passkeys/register/verify", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(credential),
});

[!NOTE] In practice, the options need to be transformed between the server’s JSON format and the WebAuthn API’s format (some fields are base64url-encoded on the wire but need to be ArrayBuffers in the browser). The @simplewebauthn/browser package handles this conversion. For this course, we focus on the server side.

Exercises

Exercise 1: Call the registration options endpoint. Inspect the response: you should see a challenge, rp (relying party), user, and pubKeyCredParams.

Exercise 2: What is the rpID? It is the domain that the passkey is bound to. In development, it is localhost. In production, it would be yourapp.com.

Exercise 3: Why do we store the challenge in the database with an expiry? (Because the browser’s response comes in a separate request. We need to match the response to the challenge.)

What does the counter in the passkeys table prevent?

← What Are Passkeys? Authentication Flow →

© 2026 hectoday. All rights reserved.