hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Securing Your API with @hectoday/http

The Threat Landscape

  • What Could Go Wrong
  • Project Setup

Brute-Force Protection

  • Rate Limiting Login Attempts
  • Account Lockout
  • Timing Attack Prevention

CSRF Protection

  • What Is CSRF?
  • CSRF Tokens
  • CSRF for API Consumers

Token Hardening

  • Refresh Token Rotation
  • Token Revocation
  • Secure Token Storage

Password Reset

  • The Password Reset Flow
  • Building the Reset Routes
  • Reset Security

Putting It All Together

  • Security Headers
  • Logging and Monitoring
  • Security Checklist
  • Capstone: Hardened Auth API

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 revokeUserTokens from refresh-tokens.ts (which you built in Section 4) and deleteUserSessions from sessions.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?

← The Password Reset Flow Reset Security →

© 2026 hectoday. All rights reserved.