SaaSsecuritybcryptauthenticationtokens

Building a Secure Password Reset Flow

OWASP compliantkey outcome

· Cosyslabs

Password reset is one of the most common attack vectors in web applications. Weak implementations use short-lived tokens with poor entropy, store reset tokens in plaintext, or allow tokens to be reused after the password is changed. This walkthrough covers the security requirements and implementation of a production-grade password reset flow.

Security Requirements

A secure password reset flow must satisfy:

  1. High-entropy tokens: reset tokens must have at least 128 bits of randomness
  2. Short expiry: tokens expire after 15–60 minutes
  3. Single use: tokens are invalidated immediately after use
  4. Hashed storage: reset tokens stored in the database must be hashed (they are bearer credentials)
  5. Account enumeration prevention: the response must not reveal whether an email exists
  6. Rate limiting: password reset requests must be rate limited per email
  7. Timing attack prevention: token comparison must be constant-time

The Flow

1. User submits email → POST /auth/forgot-password
2. Server generates token, hashes it, stores in DB with expiry
3. Server sends email with raw token in URL
4. User clicks link → GET /reset-password?token=...
5. User submits new password → POST /auth/reset-password
6. Server hashes the submitted token, looks up matching hash in DB
7. If found and not expired: hash new password, update user, invalidate token
8. Redirect to login

Generating the Reset Token

import { randomBytes, timingSafeEqual, createHash } from "crypto";

function generateResetToken() {
  // 32 random bytes = 256 bits of entropy
  const rawToken = randomBytes(32);
  
  // URL-safe Base64 encoding for the link
  const tokenForUrl = rawToken.toString("base64url");
  
  // SHA-256 hash for storage (token in DB is hashed, not plaintext)
  const tokenHash = createHash("sha256").update(rawToken).digest("hex");
  
  return { tokenForUrl, tokenHash };
}

Why hash the stored token? If an attacker gains read access to the database (SQL injection, backup theft), they cannot use the stored hash to reset passwords. The raw token is only in the email link.

Note: use SHA-256 (not bcrypt) for token storage — tokens are already high-entropy and don't need the computational expense of bcrypt. bcrypt is for low-entropy user passwords.

Handling the Request

import rateLimit from "express-rate-limit";

// Rate limit: max 5 requests per hour per IP
const resetRateLimit = rateLimit({
  windowMs: 60 * 60 * 1000,
  max: 5,
  message: "Too many reset requests. Please try again in an hour.",
  standardHeaders: true,
  legacyHeaders: false,
});

app.post("/auth/forgot-password", resetRateLimit, async (req, res) => {
  const { email } = req.body;
  
  // ALWAYS respond with success, even if email not found
  // This prevents account enumeration
  const successResponse = {
    message: "If an account with that email exists, a reset link has been sent."
  };
  
  const user = await db.users.findByEmail(email);
  
  if (!user) {
    // Do not reveal that the email was not found
    // Add a small delay to match the time taken when user exists
    await new Promise(resolve => setTimeout(resolve, 200));
    return res.json(successResponse);
  }
  
  // Invalidate any existing reset tokens for this user
  await db.passwordResets.deleteByUserId(user.id);
  
  // Generate new token
  const { tokenForUrl, tokenHash } = generateResetToken();
  
  // Store hashed token with expiry
  const expiresAt = new Date(Date.now() + 30 * 60 * 1000); // 30 minutes
  
  await db.passwordResets.create({
    userId: user.id,
    tokenHash,
    expiresAt,
    usedAt: null,
  });
  
  // Send email with raw token in URL
  const resetUrl = `https://yourapp.com/reset-password?token=${tokenForUrl}`;
  await email.send({
    to: user.email,
    subject: "Password Reset Request",
    text: `Reset your password: ${resetUrl}\n\nThis link expires in 30 minutes.`,
  });
  
  return res.json(successResponse);
});

Processing the Reset

import bcrypt from "bcrypt";
import { timingSafeEqual, createHash } from "crypto";

app.post("/auth/reset-password", async (req, res) => {
  const { token, newPassword, confirmPassword } = req.body;
  
  // Validate inputs
  if (!token || !newPassword || newPassword !== confirmPassword) {
    return res.status(400).json({ error: "Invalid request" });
  }
  
  // Password strength check
  if (newPassword.length < 12) {
    return res.status(400).json({ error: "Password must be at least 12 characters" });
  }
  
  // Hash the submitted token to find it in the database
  let rawTokenBuffer;
  try {
    rawTokenBuffer = Buffer.from(token, "base64url");
  } catch {
    return res.status(400).json({ error: "Invalid token format" });
  }
  
  const submittedHash = createHash("sha256").update(rawTokenBuffer).digest("hex");
  
  // Look up the token record
  const resetRecord = await db.passwordResets.findByTokenHash(submittedHash);
  
  // All error cases return the same error to prevent information leakage
  const invalidTokenError = { error: "Reset link is invalid or has expired" };
  
  if (!resetRecord) {
    return res.status(400).json(invalidTokenError);
  }
  
  if (resetRecord.usedAt) {
    // Token already used — possible replay attack
    // Consider alerting security team
    return res.status(400).json(invalidTokenError);
  }
  
  if (new Date() > resetRecord.expiresAt) {
    return res.status(400).json(invalidTokenError);
  }
  
  // Mark token as used BEFORE updating the password
  // (prevents race conditions where two simultaneous requests both succeed)
  const marked = await db.passwordResets.markUsed(resetRecord.id);
  if (!marked) {
    // Another request already used this token
    return res.status(400).json(invalidTokenError);
  }
  
  // Hash the new password with bcrypt
  const BCRYPT_ROUNDS = 12;
  const passwordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
  
  // Update the user's password
  await db.users.updatePassword(resetRecord.userId, passwordHash);
  
  // Invalidate all active sessions for this user
  await db.sessions.deleteByUserId(resetRecord.userId);
  
  // Optionally notify the user that their password was changed
  const user = await db.users.findById(resetRecord.userId);
  await email.send({
    to: user.email,
    subject: "Your password was changed",
    text: "Your password was successfully changed. If you did not do this, contact support immediately.",
  });
  
  return res.json({ message: "Password changed successfully. Please log in." });
});

The Database Schema

CREATE TABLE password_resets (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id     UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  token_hash  TEXT NOT NULL UNIQUE,  -- SHA-256 hex, never the raw token
  expires_at  TIMESTAMPTZ NOT NULL,
  used_at     TIMESTAMPTZ,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Index for lookup by token hash
CREATE INDEX idx_password_resets_token_hash ON password_resets(token_hash);

-- Index for cleanup job
CREATE INDEX idx_password_resets_expires_at ON password_resets(expires_at);

-- Clean up expired tokens nightly
-- DELETE FROM password_resets WHERE expires_at < NOW() - INTERVAL '1 day';

Preventing Timing Attacks

Without constant-time comparison, an attacker could measure response times to determine how many characters of a token matched:

// VULNERABLE — JavaScript string comparison short-circuits
if (submittedToken === storedToken) { ... }

// SAFE — constant time regardless of where mismatch occurs
// By hashing both sides first, we only need to compare fixed-length hex strings
// createHash("sha256") produces 64-char hex strings
if (timingSafeEqual(
  Buffer.from(submittedHash, "hex"),
  Buffer.from(storedHash, "hex")
)) { ... }

By hashing the submitted token before database lookup, the database query itself prevents timing correlation — the query time depends on the index lookup, not character matching.

Security Properties Achieved

RequirementImplementation
128+ bits entropy32 random bytes = 256 bits
Short expiry30-minute expiry enforced server-side
Single useused_at field set atomically before password change
Hashed storageSHA-256 of raw token stored, never raw token
No account enumerationIdentical response for existing/non-existing emails
Rate limiting5 requests/hour/IP via express-rate-limit
Timing attack preventionHash-then-compare, constant-time comparison
Session invalidationAll sessions revoked after password change

Common Mistakes to Avoid

Never use sequential or predictable tokens: reset_${userId}_${timestamp} is trivially guessable.

Never store the raw token in the database: treat reset tokens like passwords — store only the hash.

Never reveal whether an email exists: the response to "email not found" must be identical to "email found and reset sent."

Never allow token reuse: mark tokens as used atomically with a database-level unique constraint.

Always invalidate sessions after password change: a user who changes their password due to account compromise needs all existing sessions terminated.