hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Production Auth Patterns with @hectoday/http

Before They Start

  • Why Production Auth Is Different
  • Project Setup

Email Verification

  • Why Verify Emails
  • Building Email Verification
  • Restricting Unverified Accounts

Session Management

  • Tracking Sessions Across Devices
  • Listing and Revoking Sessions
  • Session Security

Step-Up Authentication

  • What Is Step-Up Auth
  • Building Step-Up Auth
  • Applying Step-Up to Sensitive Routes

Account Deletion

  • The Right to Be Forgotten
  • Building Account Deletion
  • Data Cleanup

SAML and Enterprise SSO

  • What Is SAML
  • Building a SAML Service Provider
  • Just-in-Time Provisioning

Putting It All Together

  • Production Auth Checklist
  • Capstone: Production-Ready Auth

Building Email Verification

The same token pattern, again

If you completed the Securing Your API course (password reset) or the 2FA course (magic links), this will feel familiar. Email verification uses the same pattern: generate a random token, hash it, store the hash, email the unhashed token as a link, verify by hashing the submitted token and comparing.

The verification module

// src/verification.ts
import db from "./db.js";

const EXPIRY = 24 * 60 * 60 * 1000; // 24 hours

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("");
}

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

  // Delete any existing tokens for this user
  db.prepare("DELETE FROM email_verifications WHERE user_id = ?").run(userId);

  db.prepare(
    "INSERT INTO email_verifications (id, user_id, token_hash, expires_at) VALUES (?, ?, ?, ?)",
  ).run(id, userId, tokenHash, expiresAt);

  return token;
}

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

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

  // Single-use: delete after consumption
  db.prepare("DELETE FROM email_verifications WHERE id = ?").run(row.id);

  return row.user_id;
}

24-hour expiry is more generous than magic links (15 minutes) because the user might not check their email immediately after signup.

Update the signup route

Send a verification email when a user signs up:

// src/routes/auth.ts — update POST /signup
route.post("/signup", {
  request: { body: SignupBody },
  resolve: async (c) => {
    if (!c.input.ok) return Response.json({ error: c.input.issues }, { status: 400 });
    const { email, password, name } = c.input.body;

    // Check for existing user
    const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(email);
    if (existing) {
      return Response.json({ error: "Email already registered" }, { status: 409 });
    }

    // Create the user (unverified)
    const id = crypto.randomUUID();
    const passwordHash = await bcrypt.hash(password, 10);
    db.prepare(
      "INSERT INTO users (id, email, name, password_hash, email_verified) VALUES (?, ?, ?, ?, 0)"
    ).run(id, email, name, passwordHash);

    // Generate and "send" verification email
    const token = await createVerificationToken(id);
    const verifyUrl = `http://localhost:3000/verify-email?token=${token}`;
    console.log(`\nšŸ“§ Verify email for ${email}:\n${verifyUrl}\n`);

    // Create session (user can log in immediately, but features are restricted)
    const sessionId = createSession(id);

    return Response.json(
      {
        user: { id, email, name, emailVerified: false },
        message: "Account created. Check your email to verify.",
      },
      { status: 201, headers: { "set-cookie": sessionCookie(sessionId) } },
    );
  },
}),

The user is logged in immediately but email_verified is 0. The next lesson restricts features for unverified users.

The verification endpoint

// src/routes/verification.ts
import { route, group } from "@hectoday/http";
import db from "../db.js";
import { consumeVerificationToken, createVerificationToken } from "../verification.js";
import { authenticate } from "../auth.js";

export const verificationRoutes = group([
  // Verify email via link
  route.get("/verify-email", {
    resolve: async (c) => {
      const token = new URL(c.request.url).searchParams.get("token");
      if (!token) {
        return Response.json({ error: "Missing token" }, { status: 400 });
      }

      const userId = await consumeVerificationToken(token);
      if (!userId) {
        return Response.json({ error: "Invalid or expired token" }, { status: 400 });
      }

      db.prepare("UPDATE users SET email_verified = 1 WHERE id = ?").run(userId);

      return Response.json({ message: "Email verified successfully." });
    },
  }),

  // Resend verification email
  route.post("/resend-verification", {
    resolve: async (c) => {
      const user = authenticate(c.request);
      if (user instanceof Response) return user;

      const row = db.prepare("SELECT email_verified FROM users WHERE id = ?").get(user.id) as {
        email_verified: number;
      };

      if (row.email_verified === 1) {
        return Response.json({ message: "Email is already verified." });
      }

      const token = await createVerificationToken(user.id);
      const verifyUrl = `http://localhost:3000/verify-email?token=${token}`;
      console.log(`\nšŸ“§ Resend verification for ${user.email}:\n${verifyUrl}\n`);

      return Response.json({ message: "Verification email sent." });
    },
  }),
]);

The resend endpoint requires authentication (the user must be logged in) and checks that the email is not already verified.

The flow

# Sign up
curl -c cookies.txt -X POST http://localhost:3000/signup \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","password":"pass123","name":"New User"}'
# Check server console for verification URL

# Verify
curl "http://localhost:3000/verify-email?token=TOKEN_HERE"
# { "message": "Email verified successfully." }

Exercises

Exercise 1: Sign up and verify. Check the email_verified column in the database before and after.

Exercise 2: Try verifying with the same token twice. The second attempt should fail (single-use).

Exercise 3: Try verifying after 24 hours (or temporarily set EXPIRY to 5 seconds). It should fail.

Exercise 4: Call the resend endpoint. A new token should be generated and the old one deleted.

Why is the verification token expiry (24 hours) longer than the magic link expiry (15 minutes)?

← Why Verify Emails Restricting Unverified Accounts →

© 2026 hectoday. All rights reserved.