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

Reset Security

Security decisions we already made

The previous lesson included several security measures baked into the code. This lesson explains why each one matters, so you understand the reasoning and can apply the same thinking to other features.

Hashing the reset token

We hash the token with SHA-256 before storing it:

const tokenHash = await hashToken(token);
store.set(tokenHash, { tokenHash, userId, createdAt: Date.now() });

Why: If your database is breached, the attacker sees hashes, not usable tokens. They cannot use the hashes to reset anyone’s password.

Why SHA-256 and not bcrypt? Reset tokens are random UUIDs (122 bits of entropy). There are no common passwords to guess, no dictionary attacks to run, no rainbow tables to build. SHA-256 is fast and sufficient for high-entropy inputs. bcrypt’s intentional slowness is designed for low-entropy inputs (human passwords).

Same response for found and not-found

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

Why: Different responses for existing and non-existing emails let an attacker enumerate which emails are registered. They send reset requests for a list of emails and watch the responses. Identical responses reveal nothing.

The tradeoff: A legitimate user who mistypes their email does not get feedback that the email was wrong. They wait for a reset email that never arrives. This is an accepted UX cost for the security benefit. You can add a note on the reset page: “If you don’t receive an email within a few minutes, check your spam folder or try a different email address.”

Rate limiting reset requests

const limit = rateLimit(`reset:${email}`, 3, 60 * 60 * 1000);

Why: Without rate limiting, an attacker can:

  • Flood a user’s inbox with reset emails (harassment)
  • Generate thousands of tokens, hoping to find a collision or timing weakness
  • Use the reset endpoint for email enumeration by timing differences (even if the response text is identical, generating a token and “sending” an email takes longer than doing nothing)

Three requests per email per hour is generous for legitimate use and restrictive for abuse.

[!TIP] For the timing side-channel (existing emails take longer because they generate a token and send an email), you can add a small random delay to the “not found” path. This makes timing-based enumeration unreliable:

if (!user) {
  await new Promise((r) => setTimeout(r, Math.random() * 200));
}

Session invalidation after reset

deleteUserSessions(userId);
revokeUserTokens(userId);

Why: A password reset implies the old password may have been compromised. Any sessions or tokens created with the old password should be invalidated. This includes:

  • The user’s own sessions on other devices
  • An attacker’s sessions if they had access to the old password
  • Any refresh tokens that could generate new access tokens

The user who just reset their password needs to log in again with the new one.

Single-use tokens

store.delete(tokenHash);

Why: After a reset token is used, it is deleted. If the email was:

  • Forwarded to someone else
  • Screenshot by a shoulder-surfer
  • Stored in a compromised email account

The token cannot be reused. The password has already been changed, and the token is gone.

Token expiry (1 hour)

const MAX_AGE = 60 * 60 * 1000; // 1 hour

Why: An unused reset token is a liability. If the user requested a reset but then remembered their password, the token sits in their inbox. If their email is compromised days later, an old token should not still be valid.

One hour is a common default. Some apps use 15 minutes for higher security.

What we did not build (but you should consider)

Notification email on password change. After a successful reset, send a second email: “Your password was just changed. If this was not you, contact support immediately.” This alerts the user if an attacker reset their password.

Link invalidation on new request. If a user requests a reset twice, the first token should be invalidated. Otherwise, both tokens are valid, and an attacker who intercepted the first one can still use it. Add a cleanup step in createResetToken that deletes any existing tokens for the same user.

CAPTCHA on the reset request form. Prevents automated reset request floods without rate limiting alone. Useful if your rate limiting is too lenient or easily bypassed.

Exercises

Exercise 1: Modify createResetToken to invalidate any existing reset tokens for the same user before creating a new one. This ensures only the most recent token is valid.

Exercise 2: Add the timing delay to the “not found” path. Measure response times for existing and non-existing emails before and after the change. The delay should make them indistinguishable.

Exercise 3: Add a console.log that simulates a “password changed” notification email. After a successful reset, log: "Notification sent to ${email}: your password was changed."

Why do we use SHA-256 instead of bcrypt for hashing reset tokens?

A user requests a password reset but then remembers their password and does not use the token. What happens to the token?

← Building the Reset Routes Security Headers →

© 2026 hectoday. All rights reserved.