Building the Reset Routes
The reset token store
Create src/reset-tokens.ts:
// src/reset-tokens.ts
interface ResetEntry {
tokenHash: string;
userId: string;
createdAt: number;
}
const store = new Map<string, ResetEntry>(); // keyed by tokenHash
const MAX_AGE = 60 * 60 * 1000; // 1 hour
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 createResetToken(userId: string): Promise<string> {
const token = crypto.randomUUID();
const tokenHash = await hashToken(token);
store.set(tokenHash, {
tokenHash,
userId,
createdAt: Date.now(),
});
return token; // the unhashed token goes in the email
}
export async function consumeResetToken(token: string): Promise<string | null> {
const tokenHash = await hashToken(token);
const entry = store.get(tokenHash);
if (!entry) return null;
// Expired
if (Date.now() - entry.createdAt > MAX_AGE) {
store.delete(tokenHash);
return null;
}
// Single-use: delete after consumption
store.delete(tokenHash);
return entry.userId;
} The flow:
createResetToken generates a random UUID, hashes it with SHA-256, stores the hash, and returns the unhashed token. The unhashed token goes in the email link. The hash is what we store.
consumeResetToken hashes the submitted token, looks up the hash, checks expiry, deletes the entry (single-use), and returns the user ID.
We use crypto.subtle.digest("SHA-256", ...) — the Web Crypto API built into Node.js. No external library needed. SHA-256 is fast (unlike bcrypt), which is fine because the tokens are random UUIDs with high entropy. An attacker cannot build a rainbow table for random UUIDs.
The forgot-password route
Create src/routes/reset.ts:
[!NOTE] This file imports
revokeUserTokensfromrefresh-tokens.ts(which you built in Section 4) anddeleteUserSessionsfromsessions.ts(which you updated in the Project Setup lesson). Both are used after a successful password reset to force the user and any attacker to re-authenticate.
// src/routes/reset.ts
import * as z from "zod/v4";
import { route, group } from "@hectoday/http";
import { findByEmail, users } from "../db.js";
import { createResetToken, consumeResetToken } from "../reset-tokens.js";
import { deleteUserSessions } from "../sessions.js";
import { revokeUserTokens } from "../refresh-tokens.js";
import { log } from "../logger.js";
import { getClientIp } from "../ip.js";
import { rateLimit } from "../rate-limit.js";
import bcrypt from "bcryptjs";
const ForgotPasswordBody = z.object({
email: z.email(),
});
const ResetPasswordBody = z.object({
token: z.string().min(1),
newPassword: z.string().min(8, "Password must be at least 8 characters"),
});
export const resetRoutes = group([
route.post("/forgot-password", {
request: { body: ForgotPasswordBody },
resolve: async (c) => {
if (!c.input.ok) {
return Response.json({ error: c.input.issues }, { status: 400 });
}
const { email } = c.input.body;
const ip = getClientIp(c.request);
// Rate limit: 3 reset requests per email per hour
const limit = rateLimit(`reset:${email}`, 3, 60 * 60 * 1000);
if (!limit.allowed) {
// Still return 200 to not reveal whether the email exists
return Response.json({
message: "If an account with that email exists, a reset link has been sent.",
});
}
const user = findByEmail(email);
if (user) {
const token = await createResetToken(user.id);
const resetUrl = `http://localhost:3000/reset-password?token=${token}`;
// In production, send an actual email.
// For this course, log the link.
log("reset_token_created", { email, resetUrl });
console.log(`\n📧 Password reset link for ${email}:\n${resetUrl}\n`);
} else {
log("reset_requested_unknown_email", { email, ip });
}
// Always return the same response
return Response.json({
message: "If an account with that email exists, a reset link has been sent.",
});
},
}),
route.post("/reset-password", {
request: { body: ResetPasswordBody },
resolve: async (c) => {
if (!c.input.ok) {
return Response.json({ error: c.input.issues }, { status: 400 });
}
const { token, newPassword } = c.input.body;
const ip = getClientIp(c.request);
const userId = await consumeResetToken(token);
if (!userId) {
log("reset_failed", { ip, reason: "invalid_or_expired_token" });
return Response.json({ error: "Invalid or expired reset token" }, { status: 400 });
}
const user = users.get(userId);
if (!user) {
return Response.json({ error: "User not found" }, { status: 400 });
}
// Update the password
user.passwordHash = await bcrypt.hash(newPassword, 10);
// Invalidate all sessions and tokens
deleteUserSessions(userId);
revokeUserTokens(userId);
log("password_reset", { userId, ip });
return Response.json({ message: "Password has been reset. Please log in." });
},
}),
]); Walkthrough
POST /forgot-password
Rate limiting: 3 requests per email per hour. Without this, an attacker could flood a user’s inbox with reset emails.
Same response always: Whether the email exists or not, the response is identical: “If an account with that email exists, a reset link has been sent.” This prevents email enumeration. The attacker cannot tell which emails are registered.
Logging: We log both found and not-found cases. The logs help you monitor for suspicious patterns (many reset requests for different emails from one IP), but the user-facing response reveals nothing.
“Sending” the email: We log the reset URL to the console. In production, you would call an email service here.
POST /reset-password
Token consumption: consumeResetToken hashes the submitted token, looks it up, checks expiry, and deletes it (single-use). If anything fails, we return a generic error.
Password update: Hash the new password with bcrypt and replace the old hash.
Session invalidation: deleteUserSessions and revokeUserTokens force the user (and any attacker who had access) to re-authenticate.
Wire it in
Add to src/app.ts:
import { resetRoutes } from "./routes/reset.js";
// In the routes array:
...resetRoutes, Try it out
# Request a reset
curl -X POST http://localhost:3000/forgot-password \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]"}'
# Check the server console for the reset URL
# Copy the token from the URL
# Reset the password
curl -X POST http://localhost:3000/reset-password \
-H "Content-Type: application/json" \
-d '{"token": "YOUR_TOKEN_HERE", "newPassword": "newpassword123"}'
# Log in with the new password
curl -X POST http://localhost:3000/login \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]", "password": "newpassword123"}' Exercises
Exercise 1: Request a reset, copy the token, but do not use it. Wait (or temporarily set MAX_AGE to 5 seconds) and then try to use it. It should fail because the token expired.
Exercise 2: Request a reset and use the token to reset the password. Then try to use the same token again. It should fail because the token is single-use (deleted after first consumption).
Exercise 3: Log in, note your session. Request a password reset and complete it. Try to use the old session cookie. It should fail with 401 because all sessions were invalidated.
Why does POST /forgot-password return the same response regardless of whether the email exists?