Password Reset Email Flow

Forgotten passwords are a fact of life for every authenticated application. The password reset flow is security-sensitive โ€” a bug here allows account takeover โ€” so every step must be implemented carefully: use a cryptographically random token, store only its hash, set a short expiry, use a consistent response that does not reveal whether an email is registered, and hash the new password through the Mongoose pre-save hook. In this lesson you will build the complete password reset flow โ€” from the React “Forgot Password” form through the Nodemailer reset email to the Express reset endpoint.

The Password Reset Flow

Step 1 โ€” User submits their email on the Forgot Password page
  POST /api/auth/forgot-password  { email: 'user@example.com' }

Step 2 โ€” Server: always returns the same message (no email enumeration)
  if user exists:
    generate rawToken
    store SHA-256(rawToken) in user.passwordResetToken
    store expiry (now + 1 hour) in user.passwordResetExpires
    send email with: CLIENT_URL/reset-password/RAW_TOKEN
  Response: 200 "If that email is registered, a reset link has been sent"

Step 3 โ€” User clicks the link in their email
  React: /reset-password/:token page โ†’ shows new password form

Step 4 โ€” User submits new password
  POST /api/auth/reset-password/:token  { password: 'NewPass@1234' }

Step 5 โ€” Server verifies the token
  hash the received token โ†’ find user where hash matches + not expired
  set user.password = new password (pre-save hook hashes it)
  clear passwordResetToken + passwordResetExpires
  respond 200 โ€” optionally sign a new JWT to auto-login

Step 6 โ€” React redirects to /login or dashboard
Note: Token expiry for password resets should be much shorter than for email verification โ€” 1 hour is standard. A reset token is a high-value target: anyone who intercepts it can take over the account. Email interception is rare but possible, so limiting the token’s valid window to 1 hour significantly reduces the risk. After resetting the password, immediately invalidate any existing tokens (including JWTs) by adding a passwordChangedAt field and checking it in the protect middleware.
Tip: After a successful password reset, issue a new JWT and auto-log the user in โ€” they just proved ownership of the email address, so requiring them to log in again is unnecessary friction. Return the token in the reset response body, just like the login endpoint does. The React client stores it and the user lands on the dashboard without an extra login step.
Warning: Always return the exact same response message and HTTP status code whether or not the email is registered. If you return 404 for “email not registered” and 200 for “reset email sent”, an attacker can probe your API to enumerate all registered email addresses and then target those accounts. The canonical response is 200 with “If that email is registered, a reset link has been sent” โ€” regardless of whether the user exists.

User Schema โ€” Reset Fields

// Add to server/src/models/User.js
passwordResetToken: {
  type:   String,
  select: false,
},
passwordResetExpires: {
  type:   Date,
  select: false,
},
passwordChangedAt: {   // tracks when password last changed
  type:   Date,
  select: false,
},

The Forgot Password Endpoint

// @desc    Request a password reset email
// @route   POST /api/auth/forgot-password
// @access  Public
const forgotPassword = asyncHandler(async (req, res) => {
  const { email } = req.body;

  // ALWAYS return the same response โ€” prevents email enumeration
  const GENERIC_RESPONSE = {
    success: true,
    message: 'If that email is registered, a reset link has been sent.',
  };

  const user = await User.findOne({ email });
  if (!user) {
    return res.json(GENERIC_RESPONSE); // same response โ€” exit silently
  }

  // Generate reset token
  const rawToken    = require('crypto').randomBytes(32).toString('hex');
  const hashedToken = require('crypto').createHash('sha256').update(rawToken).digest('hex');
  const expiry      = new Date(Date.now() + 60 * 60 * 1000); // 1 hour

  user.passwordResetToken   = hashedToken;
  user.passwordResetExpires = expiry;
  await user.save({ validateBeforeSave: false }); // skip validators โ€” only updating token fields

  const resetUrl = `${process.env.CLIENT_URL}/reset-password/${rawToken}`;

  // Respond immediately
  res.json(GENERIC_RESPONSE);

  // Send email after responding
  sendEmail({
    to:      user.email,
    subject: 'MERN Blog โ€” Password Reset Request',
    text: `You requested a password reset.\n\nClick this link to reset:\n${resetUrl}\n\nThis link expires in 1 hour. If you did not request this, ignore this email.`,
    html: `
      <h2>Password Reset</h2>
      <p>You requested a password reset for your MERN Blog account.</p>
      <p>Click the button below to set a new password. This link expires in 1 hour.</p>
      <a href="${resetUrl}" style="background:#ef4444;color:white;padding:12px 24px;
               text-decoration:none;border-radius:6px;display:inline-block;">
        Reset Password
      </a>
      <p>If you did not request a password reset, please secure your account immediately.</p>
    `,
  }).catch(err => console.error('Reset email failed:', err.message));
});

The Reset Password Endpoint

// @desc    Reset password using token from URL
// @route   POST /api/auth/reset-password/:token
// @access  Public
const resetPassword = asyncHandler(async (req, res) => {
  const { token }    = req.params;
  const { password } = req.body;

  if (!password || password.length < 8) {
    throw new AppError('Password must be at least 8 characters', 400);
  }

  // Hash the token from the URL to compare with what is in the DB
  const hashedToken = require('crypto').createHash('sha256').update(token).digest('hex');

  const user = await User.findOne({
    passwordResetToken:   hashedToken,
    passwordResetExpires: { $gt: Date.now() }, // not expired
  }).select('+passwordResetToken +passwordResetExpires +password');

  if (!user) {
    throw new AppError('Password reset link is invalid or has expired', 400);
  }

  // Set new password โ€” pre('save') hook in User schema will hash it
  user.password             = password;
  user.passwordResetToken   = undefined;
  user.passwordResetExpires = undefined;
  user.passwordChangedAt    = new Date();
  await user.save(); // pre-save hook hashes the new password

  // Issue a new JWT and auto-login the user
  const jwtToken = signAccessToken(user._id, user.role);
  res.json({
    success: true,
    message: 'Password reset successfully.',
    token:   jwtToken,
    data:    { _id: user._id, name: user.name, email: user.email },
  });

  // Send password changed security alert
  sendEmail({
    to:      user.email,
    subject: 'Your MERN Blog password was changed',
    text:    'Your password was just changed. If this was not you, contact support immediately.',
    html:    '<p>Your MERN Blog password was changed. If this was not you, contact support immediately.</p>',
  }).catch(err => console.error('Password changed alert failed:', err.message));
});

React โ€” Forgot Password and Reset Password Pages

// ForgotPasswordPage โ€” send reset email request
function ForgotPasswordPage() {
  const [email,   setEmail]   = useState('');
  const [sent,    setSent]    = useState(false);
  const [loading, setLoading] = useState(false);

  const handleSubmit = async (e) => {
    e.preventDefault();
    setLoading(true);
    try {
      await api.post('/auth/forgot-password', { email });
      setSent(true); // always show success message โ€” consistent with server response
    } catch {
      setSent(true); // same UX โ€” do not reveal whether email exists
    } finally {
      setLoading(false);
    }
  };

  if (sent) return (
    <div className="auth-page">
      <h1>Check Your Email</h1>
      <p>If that email is registered, a reset link has been sent. Check your inbox.</p>
    </div>
  );

  return (
    <form onSubmit={handleSubmit}>
      <h1>Forgot Password</h1>
      <input type="email" value={email} onChange={e => setEmail(e.target.value)}
             placeholder="Your email address" required />
      <button type="submit" disabled={loading}>
        {loading ? 'Sending...' : 'Send Reset Link'}
      </button>
    </form>
  );
}

Common Mistakes

Mistake 1 โ€” Revealing whether an email is registered

โŒ Wrong โ€” different responses for known vs unknown email:

if (!user) return res.status(404).json({ message: 'Email not found' });
// vs
res.json({ message: 'Reset link sent' });
// Attacker knows 'user@example.com' is registered!

โœ… Correct โ€” always the same 200 response.

Mistake 2 โ€” Not using validateBeforeSave: false when saving token-only updates

โŒ Wrong โ€” validation fails because required fields are “missing” in the save context:

user.passwordResetToken = hashedToken;
await user.save(); // ValidationError: password is required (even though it is already set!)

โœ… Correct โ€” skip validators when only updating token fields:

await user.save({ validateBeforeSave: false }); // โœ“ skip validators

Mistake 3 โ€” Forgetting to clear the reset token after use

โŒ Wrong โ€” used token remains in DB, could theoretically be reused:

user.password = newPassword;
await user.save(); // passwordResetToken still in DB!

โœ… Correct โ€” clear token fields after use:

user.password             = newPassword;
user.passwordResetToken   = undefined;
user.passwordResetExpires = undefined;
await user.save(); // โœ“ token gone after reset

Quick Reference

Task Code
1-hour expiry new Date(Date.now() + 60 * 60 * 1000)
Consistent response Return same message whether user exists or not
Save token only user.save({ validateBeforeSave: false })
Find by hashed token User.findOne({ passwordResetToken: hash, passwordResetExpires: { $gt: Date.now() } })
Clear after reset user.passwordResetToken = undefined
Track change time user.passwordChangedAt = new Date()

🧠 Test Yourself

Your forgot password endpoint returns 404 Not Found when the email is not in the database and 200 OK when a reset email is sent. A security researcher flags this. Why is it a problem?