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

Authentication Flow

Logging in with a passkey

The authentication flow mirrors registration: generate options, get a browser response, verify it.

// Add to src/routes/passkeys.ts
import {
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from "@simplewebauthn/server";
import { createSession } from "../sessions.js";
import { sessionCookie } from "../cookies.js";

// Step 1: Generate authentication options
route.post("/auth/passkey/options", {
  resolve: async (c) => {
    // For discoverable credentials (passkeys), we do not need to know the user yet.
    // The browser finds the matching credential automatically.
    // For non-discoverable credentials, we would need the user's email first.

    const body = await c.request.json().catch(() => ({}));
    const email = (body as any).email;

    let allowCredentials: { id: string }[] = [];

    if (email) {
      // If email is provided, limit to that user's credentials
      const user = db.prepare("SELECT id FROM users WHERE email = ?").get(email) as any;
      if (user) {
        const creds = db.prepare("SELECT credential_id FROM passkeys WHERE user_id = ?")
          .all(user.id) as { credential_id: string }[];
        allowCredentials = creds.map((c) => ({ id: c.credential_id }));
      }
    }

    const options = await generateAuthenticationOptions({
      rpID: RP_ID,
      allowCredentials: allowCredentials.length > 0 ? allowCredentials : undefined,
      userVerification: "preferred",
    });

    // Store challenge — use a temporary session for unauthenticated requests
    const tempId = email ?? "anonymous-" + crypto.randomUUID();
    db.prepare(
      "INSERT OR REPLACE INTO webauthn_challenges (user_id, challenge, expires_at) VALUES (?, ?, ?)"
    ).run(tempId, options.challenge, Date.now() + 5 * 60 * 1000);

    return Response.json({ ...options, _challengeKey: tempId });
  },
}),

// Step 2: Verify authentication response
route.post("/auth/passkey/verify", {
  resolve: async (c) => {
    const body = await c.request.json();
    const challengeKey = body._challengeKey;

    if (!challengeKey) {
      return Response.json({ error: "Missing challenge key" }, { status: 400 });
    }

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

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

    // Find the credential in our database
    const credentialId = body.id;
    const passkey = db.prepare(
      "SELECT id, user_id, credential_id, public_key, counter FROM passkeys WHERE credential_id = ?"
    ).get(credentialId) as any;

    if (!passkey) {
      return Response.json({ error: "Unknown credential" }, { status: 401 });
    }

    try {
      const verification = await verifyAuthenticationResponse({
        response: body,
        expectedChallenge: challengeRow.challenge,
        expectedOrigin: ORIGIN,
        expectedRPID: RP_ID,
        credential: {
          id: passkey.credential_id,
          publicKey: Buffer.from(passkey.public_key, "base64url"),
          counter: passkey.counter,
        },
      });

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

      // Update the counter
      db.prepare("UPDATE passkeys SET counter = ? WHERE id = ?")
        .run(verification.authenticationInfo.newCounter, passkey.id);

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

      // Create a session
      const user = db.prepare("SELECT id, email, name FROM users WHERE id = ?")
        .get(passkey.user_id) as any;
      const sessionId = createSession(user.id);

      return Response.json(
        { user: { id: user.id, email: user.email, name: user.name } },
        { headers: { "set-cookie": sessionCookie(sessionId) } },
      );
    } catch (error) {
      return Response.json({ error: "Verification failed" }, { status: 401 });
    }
  },
}),

What each part does

generateAuthenticationOptions: Creates a challenge for the browser. If allowCredentials is provided, the browser looks for those specific credentials. If omitted (discoverable credentials / passkeys), the browser shows all available credentials for the domain.

Challenge storage: The challenge is stored temporarily, keyed by email or a random ID. Unlike registration, the user is not authenticated yet, so we cannot use their user ID.

verifyAuthenticationResponse: Verifies the browser’s response: checks the challenge, origin, and signature against the stored public key. Also checks that the counter has incremented.

Counter update: After successful verification, the counter is updated. This ensures that each authentication increments the counter, making cloned authenticator detection possible.

Session creation: On success, a full session is created, same as password login.

The client side

// Client-side JavaScript

// Step 1: Get options
const optionsRes = await fetch("/auth/passkey/options", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ email: "[email protected]" }), // optional for discoverable credentials
});
const options = await optionsRes.json();

// Step 2: Authenticate (triggers biometric prompt)
const assertion = await navigator.credentials.get({ publicKey: options });

// Step 3: Verify with server
const verifyRes = await fetch("/auth/passkey/verify", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ ...assertion, _challengeKey: options._challengeKey }),
});

The user experience

From the user’s perspective: they click “Sign in with passkey,” the browser shows a prompt (fingerprint, Face ID, or PIN), they authenticate, and they are logged in. No email, no password, no codes. The entire login is one action.

This is the best UX of any auth method — and the most secure. No shared secrets, no phishing risk, no codes to type.

Exercises

Exercise 1: Register a passkey (previous lesson), then authenticate with it. Verify a session is created.

Exercise 2: Check the counter in the database before and after authentication. It should increment.

Exercise 3: What happens if someone tries to authenticate with a credential ID that is not in your database? (They get 401 “Unknown credential.”)

Why does the authentication flow not require a password?

← Registration Flow Passkeys as Second Factor or Primary →

© 2026 hectoday. All rights reserved.