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)?