Password Security — Reset Flow, Email Verification, and Session Invalidation

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
Note: Never store password reset tokens as plain text in the database. If the database is compromised, an attacker can use any outstanding reset tokens to take over accounts. Instead, store a SHA-256 hash of the token. The user receives the plain token in the email URL; the server hashes it and compares against the stored hash. This is the same principle as bcrypt for passwords — the stored value is useless without the original plain text.
Tip: Use a job queue (Bull + Redis) for sending verification and reset emails — never send emails synchronously in route handlers. If the email service is down, a synchronous approach fails the entire registration. With a queue, the registration succeeds immediately and the email is retried automatically. Bull provides retry logic, dead-letter queues for failed jobs, and a dashboard for monitoring email delivery. For development, nodemailer with a local SMTP server like Mailhog works well.
Warning: The password reset response must be identical whether the email exists or not. If you return { 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 })

🧠 Test Yourself

A user’s database record has passwordChangedAt: "2025-01-15T10:00:00Z". An attacker has a stolen JWT with iat: 1736935200 (January 15 2025, 09:00 UTC). The JWT is valid and not expired. Should the server accept this token?