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
passwordChangedAt field and checking it in the protect middleware.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() |