Password security does not end at bcrypt hashing. A comprehensive password security system includes email verification to ensure users own the address they registered with, password reset flows that are secure against timing attacks and token reuse, and account security features like email alerts for suspicious activity and the ability to terminate all active sessions. This lesson builds the complete password lifecycle management system for the MEAN Stack task manager — from registration email verification through account recovery to session management.
Secure Token Patterns
| Token Type | Storage | Expiry | One-Time Use |
|---|---|---|---|
| Email verification | Hashed in DB | 24 hours | Yes — delete after use |
| Password reset | Hashed in DB | 10 minutes | Yes — delete after use |
| Magic link login | Hashed in DB | 15 minutes | Yes — delete after use |
| Refresh token | Hashed in DB or HttpOnly cookie | 7 days | Rotated on each use |
Password Reset Security Requirements
| Requirement | Why | Implementation |
|---|---|---|
| Cryptographically random token | Unpredictable — cannot be brute forced | crypto.randomBytes(32).toString('hex') |
| Store hashed token | If DB is leaked, tokens cannot be used | crypto.createHash('sha256').update(token).digest('hex') |
| Short expiry (10 minutes) | Limits window if email is intercepted | Date.now() + 10 * 60 * 1000 |
| Same response for unknown email | Prevents email enumeration attack | Always: “If email exists, we sent a link” |
| Invalidate after use | Prevents token reuse | Clear token and expiry fields after reset |
| Invalidate all sessions | Attacker using old session is logged out | Update passwordChangedAt timestamp |
nodemailer with a local SMTP server like Mailhog works well.{ message: 'Email not found' } for unknown emails, an attacker can enumerate your user base by trying emails one by one. Always return the same message: “If an account exists for this email, we have sent a password reset link.” This prevents email enumeration while still being helpful to legitimate users.Complete Password Lifecycle Implementation
const crypto = require('crypto');
const User = require('../models/user.model');
const emailQueue = require('../queues/email.queue');
// ── Password reset flow ───────────────────────────────────────────────────
// POST /api/v1/auth/forgot-password
exports.forgotPassword = async (req, res) => {
const { email } = req.body;
// Find user (silently) — same response regardless of whether email exists
const user = await User.findOne({ email: email.toLowerCase() });
if (user) {
// 1. Generate cryptographically random token
const resetToken = crypto.randomBytes(32).toString('hex');
// 2. Store HASHED version in DB — plain token only goes in the email
const hashedToken = crypto.createHash('sha256').update(resetToken).digest('hex');
user.passwordResetToken = hashedToken;
user.passwordResetExpires = Date.now() + 10 * 60 * 1000; // 10 minutes
await user.save({ validateBeforeSave: false });
// 3. Queue email with the plain token (NOT the hash)
const resetUrl = `${process.env.FRONTEND_URL}/auth/reset-password/${resetToken}`;
await emailQueue.add('passwordReset', {
to: user.email,
name: user.name,
resetUrl,
expiresIn: '10 minutes',
});
}
// 4. Always same response — prevents email enumeration
res.json({
success: true,
message: 'If an account exists for this email, a password reset link has been sent.',
});
};
// POST /api/v1/auth/reset-password/:token
exports.resetPassword = async (req, res) => {
const { password, confirmPassword } = req.body;
if (password !== confirmPassword) {
return res.status(400).json({ message: 'Passwords do not match' });
}
// 1. Hash the incoming token to compare against the DB
const hashedToken = crypto.createHash('sha256')
.update(req.params.token)
.digest('hex');
// 2. Find user with valid, non-expired token
const user = await User.findOne({
passwordResetToken: hashedToken,
passwordResetExpires: { $gt: Date.now() }, // token must not be expired
});
if (!user) {
return res.status(400).json({ message: 'Token is invalid or has expired' });
}
// 3. Update password — bcrypt hashing happens in pre('save') middleware
user.password = password;
user.passwordResetToken = undefined; // invalidate token
user.passwordResetExpires = undefined;
user.passwordChangedAt = new Date(); // invalidate all existing sessions
await user.save();
// 4. Invalidate all active refresh tokens
user.refreshTokens = [];
await user.save({ validateBeforeSave: false });
// 5. Queue security alert email
await emailQueue.add('passwordChanged', {
to: user.email,
name: user.name,
timestamp: new Date().toISOString(),
ip: req.ip,
});
res.json({ success: true, message: 'Password reset successfully. Please log in.' });
};
// ── Email verification ────────────────────────────────────────────────────
// After registration — send verification email
exports.sendVerificationEmail = async (user) => {
const token = crypto.randomBytes(32).toString('hex');
const hashed = crypto.createHash('sha256').update(token).digest('hex');
user.emailVerificationToken = hashed;
user.emailVerificationExpires = Date.now() + 24 * 60 * 60 * 1000; // 24 hours
await user.save({ validateBeforeSave: false });
const verifyUrl = `${process.env.FRONTEND_URL}/auth/verify-email/${token}`;
await emailQueue.add('emailVerification', { to: user.email, name: user.name, verifyUrl });
};
// GET /api/v1/auth/verify-email/:token
exports.verifyEmail = async (req, res) => {
const hashed = crypto.createHash('sha256').update(req.params.token).digest('hex');
const user = await User.findOne({
emailVerificationToken: hashed,
emailVerificationExpires: { $gt: Date.now() },
});
if (!user) return res.status(400).json({ message: 'Invalid or expired verification link' });
user.isVerified = true;
user.emailVerificationToken = undefined;
user.emailVerificationExpires = undefined;
await user.save({ validateBeforeSave: false });
res.json({ success: true, message: 'Email verified successfully' });
};
// ── JWT invalidation after password change ────────────────────────────────
// In the auth middleware — check if token was issued before password change:
function verifyAccessToken(req, res, next) {
// ... (verify signature as before) ...
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Reject tokens issued before the last password change
if (decoded.iat * 1000 < (user.passwordChangedAt?.getTime() ?? 0)) {
return res.status(401).json({ message: 'Password changed. Please log in again.' });
}
req.user = decoded;
next();
}
How It Works
Step 1 — Random Tokens Are Unpredictable
crypto.randomBytes(32) generates 32 bytes (256 bits) of cryptographically secure random data from the operating system’s entropy pool. Converted to a hex string, this is a 64-character token. With 256 bits of entropy, brute forcing would require 2^256 attempts — more than the number of atoms in the observable universe. Never use Math.random() for security tokens — it is not cryptographically secure.
Step 2 — Hashing the Token Protects Against DB Leaks
The token in the email URL is the plain random bytes. The database stores SHA-256(token). When the user clicks the link, the server computes SHA-256(token_from_url) and compares to the stored hash. If an attacker obtains database access, they have only the hash — they cannot reverse it to get the original token. This prevents a DB breach from enabling account takeovers through outstanding reset tokens.
Step 3 — passwordChangedAt Invalidates Stolen Tokens
After a password reset, all previously issued access tokens should be rejected — even if they have not yet expired. Storing passwordChangedAt timestamp and checking token.iat < user.passwordChangedAt in the auth middleware achieves this. Any token issued before the password change is rejected, forcing re-authentication. This protects against scenarios where an attacker had a stolen token and the user then reset their password to revoke access.
Step 4 — Same Response Prevents Email Enumeration
If the server returns different responses for “email found” vs “email not found”, an attacker can determine which email addresses have accounts. This enables targeted phishing. By always returning the same message (“If an account exists…”), the server reveals nothing about whether the email is registered. The legitimate user sees the email if their account exists; the attacker learns nothing.
Step 5 — Queue Emails for Reliability and Resilience
Email delivery is inherently unreliable — SMTP servers go down, rate limits are hit, and messages are delayed. Queuing with Bull/Redis decouples email sending from the request lifecycle. The HTTP request returns immediately (200 OK), while the email worker processes the job asynchronously with automatic retries on failure. Failed jobs are saved to a dead-letter queue for investigation rather than silently lost.
Common Mistakes
Mistake 1 — Storing plain reset token in the database
❌ Wrong — DB breach exposes all active reset tokens:
user.passwordResetToken = resetToken; // plain text in DB!
// DB breach: attacker can use all active reset tokens immediately
✅ Correct — store SHA-256 hash:
user.passwordResetToken = crypto.createHash('sha256').update(resetToken).digest('hex');
Mistake 2 — Different response for unknown vs known email
❌ Wrong — reveals which emails are registered:
if (!user) return res.status(404).json({ message: 'Email not found' });
// Attacker: enumerate all emails — now knows which have accounts
✅ Correct — same response always:
res.json({ message: 'If an account exists for this email, a reset link was sent.' });
// Even if user is null — response is identical
Mistake 3 — Not invalidating sessions after password reset
❌ Wrong — attacker’s stolen session continues working after victim resets password:
user.password = newPassword;
await user.save();
// Attacker's existing JWT still valid for up to 15 more minutes
✅ Correct — set passwordChangedAt to invalidate all existing tokens:
user.password = newPassword;
user.passwordChangedAt = new Date(); // all tokens issued before now are invalid
await user.save();
Quick Reference
| Task | Code |
|---|---|
| Generate reset token | crypto.randomBytes(32).toString('hex') |
| Hash for storage | crypto.createHash('sha256').update(token).digest('hex') |
| Set expiry | user.resetExpires = Date.now() + 10 * 60 * 1000 |
| Find by token | User.findOne({ resetToken: hash, resetExpires: { $gt: Date.now() } }) |
| Invalidate token | user.resetToken = undefined; user.resetExpires = undefined |
| Invalidate sessions | user.passwordChangedAt = new Date() |
| Check token age | if (decoded.iat * 1000 < user.passwordChangedAt.getTime()) |
| Queue email | await emailQueue.add('passwordReset', { to, resetUrl }) |