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

Building Magic Link Login

The magic link module

// src/magic-link.ts
import db from "./db.js";

async function hashToken(token: string): Promise<string> {
  const data = new TextEncoder().encode(token);
  const hash = await crypto.subtle.digest("SHA-256", data);
  return Array.from(new Uint8Array(hash))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

const EXPIRY = 15 * 60 * 1000; // 15 minutes

export async function createMagicLink(email: string): Promise<string> {
  const token = crypto.randomUUID();
  const tokenHash = await hashToken(token);
  const expiresAt = Date.now() + EXPIRY;
  const id = crypto.randomUUID();

  // Delete any existing unused links for this email
  db.prepare("DELETE FROM magic_links WHERE email = ? AND used = 0").run(email);

  db.prepare(
    "INSERT INTO magic_links (id, email, token_hash, expires_at, used) VALUES (?, ?, ?, ?, 0)",
  ).run(id, email, tokenHash, expiresAt);

  return token; // The unhashed token goes in the email link
}

export async function verifyMagicLink(token: string): Promise<string | null> {
  const tokenHash = await hashToken(token);
  const row = db
    .prepare("SELECT id, email, expires_at, used FROM magic_links WHERE token_hash = ?")
    .get(tokenHash) as { id: string; email: string; expires_at: number; used: number } | undefined;

  if (!row) return null;
  if (row.used === 1) return null;
  if (Date.now() > row.expires_at) {
    db.prepare("DELETE FROM magic_links WHERE id = ?").run(row.id);
    return null;
  }

  // Mark as used (single-use)
  db.prepare("UPDATE magic_links SET used = 1 WHERE id = ?").run(row.id);

  return row.email;
}

This is nearly identical to the password reset token module from the Securing Your API course. The differences: the token is tied to an email (not a user ID), and consumption returns the email (not a user ID) so we can look up or create the user.

The routes

// src/routes/magic-link.ts
import * as z from "zod/v4";
import { route, group } from "@hectoday/http";
import db from "../db.js";
import { createMagicLink, verifyMagicLink } from "../magic-link.js";
import { createSession } from "../sessions.js";
import { sessionCookie } from "../cookies.js";

const MagicLinkBody = z.object({ email: z.email() });

export const magicLinkRoutes = group([
  // Request a magic link
  route.post("/auth/magic-link", {
    request: { body: MagicLinkBody },
    resolve: async (c) => {
      if (!c.input.ok) return Response.json({ error: c.input.issues }, { status: 400 });
      const { email } = c.input.body;

      // Always return the same response, whether the email exists or not
      const user = db.prepare("SELECT id FROM users WHERE email = ?").get(email) as any;

      if (user) {
        const token = await createMagicLink(email);
        const link = `http://localhost:3000/auth/magic-link/verify?token=${token}`;

        // In production, send an actual email
        console.log(`\nšŸ“§ Magic link for ${email}:\n${link}\n`);
      }

      return Response.json({
        message: "If an account with that email exists, a login link has been sent.",
      });
    },
  }),

  // Verify the magic link
  route.get("/auth/magic-link/verify", {
    resolve: async (c) => {
      const token = new URL(c.request.url).searchParams.get("token");

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

      const email = await verifyMagicLink(token);
      if (!email) {
        return Response.json({ error: "Invalid or expired link" }, { status: 401 });
      }

      // Look up the user
      const user = db
        .prepare("SELECT id, email, name FROM users WHERE email = ?")
        .get(email) as any;
      if (!user) {
        return Response.json({ error: "User not found" }, { status: 401 });
      }

      // Create a full session (magic link is the complete auth)
      const sessionId = createSession(user.id);

      return Response.json(
        { user: { id: user.id, email: user.email, name: user.name } },
        { headers: { "set-cookie": sessionCookie(sessionId) } },
      );
    },
  }),
]);

The flow

# Request a magic link
curl -X POST http://localhost:3000/auth/magic-link \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]"}'
# { "message": "If an account with that email exists, a login link has been sent." }

# Check the server console for the link
# Copy the token from the URL

# Click the link (or curl it)
curl -c cookies.txt "http://localhost:3000/auth/magic-link/verify?token=TOKEN_HERE"
# { "user": { "id": "...", ... } }
# Session cookie is set — user is logged in

Security details

Same response for existing and non-existing emails. Prevents email enumeration, same as password reset.

15-minute expiry. Shorter than password reset (1 hour) because magic links should be used immediately. The user just requested login — they should click the link right away.

Single-use. The token is marked as used after verification. Clicking the link again fails.

Previous links invalidated. When a new link is requested, previous unused links for the same email are deleted. Only the latest link works.

Token hashing. The token is hashed before storage, same as password reset tokens.

Magic links and 2FA

If the user has 2FA enabled, should magic link login bypass it? This is a design decision:

Option A: Magic link bypasses 2FA. The email link is the full authentication. This is simpler but weakens 2FA — an attacker with email access can bypass the second factor.

Option B: Magic link replaces the password, but 2FA still required. After clicking the link, the user still needs to enter a TOTP code. This is stronger but adds friction.

For most apps, Option A is fine (magic links are for convenience-oriented users). For high-security apps, Option B is better. The choice should match your threat model.

Exercises

Exercise 1: Request a magic link. Copy the token. Use it to log in. Verify the session works.

Exercise 2: Use the token again. It should fail (single-use).

Exercise 3: Request a magic link but wait 15 minutes (or temporarily set EXPIRY to 5 seconds). The link should fail.

Exercise 4: Request two magic links for the same email. Only the second one should work (the first was deleted).

Why does the request endpoint return the same response for existing and non-existing emails?

← How Magic Links Work Security Considerations →

© 2026 hectoday. All rights reserved.