Authentication System — Register, Login, Refresh Tokens, and Password Reset

Authentication is the most security-sensitive part of any application — mistakes here compromise every user’s account and data. The Task Manager authentication system implements the complete secure flow: bcrypt password hashing, JWT access tokens with short expiry, long-lived refresh tokens stored in httpOnly cookies, email verification, password reset with time-limited tokens, and token rotation on every refresh. Each piece is deliberately designed to mitigate a specific class of attack, and the implementation draws on every authentication pattern established in Chapter 17.

Authentication Flow Overview

Action Endpoint Returns Security Mechanism
Register POST /auth/register 201 + tokens + user bcrypt hash, email verification queued
Verify email GET /auth/verify/:token 200 confirmation Time-limited token (24h), single-use
Login POST /auth/login 200 + access token + httpOnly cookie bcrypt compare, rate limited (10/15min)
Refresh token POST /auth/refresh 200 + new access token httpOnly cookie, token rotation
Logout POST /auth/logout 204 Clears httpOnly cookie, invalidates refresh token
Forgot password POST /auth/forgot-password 200 (always, even if unknown email) Rate limited, email with time-limited token
Reset password POST /auth/reset-password 200 + new tokens Token hashed in DB, invalidates all sessions
Note: The forgot-password endpoint always returns HTTP 200 with the same response body whether or not the email exists. This prevents user enumeration — an attacker cannot determine which email addresses are registered by observing different responses. The actual email is only sent if the account exists. This is a standard security pattern: never reveal whether a specific identifier (email, username) exists in your system through differential responses.
Tip: Store the refresh token as a hashed value in the database — not the raw token. If an attacker gains read access to the database, hashed refresh tokens are useless. When validating a refresh request, hash the incoming cookie token with the same algorithm (SHA-256) and compare it to the stored hash. Use crypto.createHash('sha256').update(token).digest('hex') — fast enough that the added latency is imperceptible, but makes a stolen database dump useless for forging refresh tokens.
Warning: Token rotation must invalidate the old refresh token immediately upon issuing a new one. If an attacker steals a refresh token and uses it after the legitimate user has already used it (rotation race), the server detects the reuse: the old token is already invalidated, which signals a possible theft — log it, alert the user, and invalidate all their refresh tokens. This “refresh token reuse detection” pattern is described in RFC 6749 and is the correct response to potential token theft.

Complete Authentication Implementation

// ── apps/api/src/modules/auth/auth.service.js ────────────────────────────
const crypto   = require('crypto');
const jwt      = require('jsonwebtoken');
const User     = require('../users/user.model');
const RefreshToken = require('./refresh-token.model');
const { emailQueue }      = require('../../queues');
const { AuthenticationError, ConflictError, ValidationError } = require('../../errors/app-errors');

const ACCESS_EXPIRY  = process.env.JWT_EXPIRES_IN     || '15m';
const REFRESH_EXPIRY = process.env.REFRESH_EXPIRES_IN || '7d';
const COOKIE_OPTIONS = {
    httpOnly: true,
    secure:   process.env.NODE_ENV === 'production',
    sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax',
    maxAge:   7 * 24 * 60 * 60 * 1000,  // 7 days in ms
    path:     '/api/v1/auth',            // only sent to auth endpoints
};

function generateAccessToken(user) {
    return jwt.sign(
        { sub: user._id.toString(), email: user.email, role: user.role },
        process.env.JWT_SECRET,
        { expiresIn: ACCESS_EXPIRY }
    );
}

async function generateRefreshToken(userId) {
    const raw    = crypto.randomBytes(64).toString('hex');
    const hashed = crypto.createHash('sha256').update(raw).digest('hex');

    await RefreshToken.create({
        user:      userId,
        tokenHash: hashed,
        expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
    });

    return raw;   // raw token goes to client, hash stays in DB
}

exports.register = async ({ name, email, password }) => {
    if (await User.exists({ email })) {
        throw new ConflictError('Email already registered', 'email');
    }

    const user  = await User.create({ name, email, password, isVerified: false });
    const token = crypto.randomBytes(32).toString('hex');

    user.verificationToken       = crypto.createHash('sha256').update(token).digest('hex');
    user.verificationTokenExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000);
    await user.save({ validateBeforeSave: false });

    await emailQueue.add('verifyEmail', {
        type: 'VERIFY_EMAIL',
        to:   email,
        data: { name, verifyUrl: `${process.env.CLIENT_URL}/auth/verify?token=${token}` },
    }, { priority: 1 });

    const accessToken  = generateAccessToken(user);
    const refreshToken = await generateRefreshToken(user._id);
    return { user, accessToken, refreshToken };
};

exports.login = async ({ email, password }) => {
    const user = await User.findOne({ email, isActive: true }).select('+password');
    if (!user || !(await user.comparePassword(password))) {
        throw new AuthenticationError('Invalid email or password');
    }

    user.lastLoginAt = new Date();
    await user.save({ validateBeforeSave: false });

    const accessToken  = generateAccessToken(user);
    const refreshToken = await generateRefreshToken(user._id);
    return { user, accessToken, refreshToken };
};

exports.refresh = async (rawToken) => {
    if (!rawToken) throw new AuthenticationError('Refresh token required', 'NO_REFRESH_TOKEN');

    const hashed  = crypto.createHash('sha256').update(rawToken).digest('hex');
    const stored  = await RefreshToken.findOne({ tokenHash: hashed })
                                      .populate('user');

    if (!stored || stored.expiresAt < new Date()) {
        // If expired token found — possible reuse — invalidate all for user
        if (stored) {
            await RefreshToken.deleteMany({ user: stored.user });
        }
        throw new AuthenticationError('Session expired. Please log in again.', 'TOKEN_EXPIRED');
    }

    // Token rotation — delete old, issue new
    await stored.deleteOne();
    const newRefreshToken = await generateRefreshToken(stored.user._id);
    const accessToken     = generateAccessToken(stored.user);

    return { user: stored.user, accessToken, newRefreshToken };
};

exports.logout = async (rawToken) => {
    if (!rawToken) return;
    const hashed = crypto.createHash('sha256').update(rawToken).digest('hex');
    await RefreshToken.deleteOne({ tokenHash: hashed });
};

exports.forgotPassword = async (email) => {
    const user = await User.findOne({ email });
    if (!user) return;  // silent — don't reveal if email exists

    const token  = crypto.randomBytes(32).toString('hex');
    user.resetToken       = crypto.createHash('sha256').update(token).digest('hex');
    user.resetTokenExpiry = new Date(Date.now() + 60 * 60 * 1000);  // 1 hour
    await user.save({ validateBeforeSave: false });

    await emailQueue.add('passwordReset', {
        type: 'PASSWORD_RESET',
        to:   email,
        data: { name: user.name, resetUrl: `${process.env.CLIENT_URL}/auth/reset?token=${token}` },
    }, { priority: 1, attempts: 2 });
};

exports.resetPassword = async (rawToken, newPassword) => {
    const hashed = crypto.createHash('sha256').update(rawToken).digest('hex');
    const user   = await User.findOne({
        resetToken:       hashed,
        resetTokenExpiry: { $gt: new Date() },
    });
    if (!user) throw new ValidationError('Reset token invalid or expired', { token: 'Invalid or expired' });

    user.password         = newPassword;
    user.resetToken       = undefined;
    user.resetTokenExpiry = undefined;
    await user.save();

    // Invalidate all refresh tokens — logout all sessions
    await RefreshToken.deleteMany({ user: user._id });

    const accessToken  = generateAccessToken(user);
    const refreshToken = await generateRefreshToken(user._id);
    return { user, accessToken, refreshToken };
};

// ── Auth controller ───────────────────────────────────────────────────────
// apps/api/src/modules/auth/auth.controller.js
const authService = require('./auth.service');

exports.register = async (req, res) => {
    const { user, accessToken, refreshToken } = await authService.register(req.body);
    res.cookie('refreshToken', refreshToken, COOKIE_OPTIONS);
    res.status(201).json({ success: true, data: { accessToken, user } });
};

exports.login = async (req, res) => {
    const { user, accessToken, refreshToken } = await authService.login(req.body);
    res.cookie('refreshToken', refreshToken, COOKIE_OPTIONS);
    res.json({ success: true, data: { accessToken, user } });
};

exports.refresh = async (req, res) => {
    const { user, accessToken, newRefreshToken } = await authService.refresh(req.cookies.refreshToken);
    res.cookie('refreshToken', newRefreshToken, COOKIE_OPTIONS);
    res.json({ success: true, data: { accessToken, user } });
};

exports.logout = async (req, res) => {
    await authService.logout(req.cookies.refreshToken);
    res.clearCookie('refreshToken', { path: '/api/v1/auth' });
    res.status(204).send();
};

exports.forgotPassword = async (req, res) => {
    await authService.forgotPassword(req.body.email);
    res.json({ success: true, message: 'If that email is registered, a reset link has been sent.' });
};

exports.resetPassword = async (req, res) => {
    const { user, accessToken, refreshToken } = await authService.resetPassword(
        req.body.token, req.body.password
    );
    res.cookie('refreshToken', refreshToken, COOKIE_OPTIONS);
    res.json({ success: true, data: { accessToken, user } });
};

How It Works

The refresh token cookie has httpOnly: true — JavaScript cannot read it. secure: true (in production) means it only travels over HTTPS. path: '/api/v1/auth' means the browser only sends the cookie to auth endpoints — not to task or workspace endpoints, reducing exposure. sameSite: 'strict' prevents cross-site requests from including the cookie. Combined, these flags make the refresh token as protected as browser technology allows.

Step 2 — Stored Hash Protects Tokens at Rest

The raw refresh token (64 random bytes, hex-encoded) is given to the client. The SHA-256 hash of this token is stored in the database. SHA-256 is a one-way function — the stored hash cannot be reversed to retrieve the raw token. If an attacker reads the database, the hashes are useless for making refresh requests. On each refresh request, the server hashes the incoming cookie value and looks up the hash — never the raw token.

Step 3 — Token Rotation Issues a New Token on Every Refresh

After validating the incoming refresh token, the service immediately deletes it and creates a new one. This means each refresh token can only be used once. If the same refresh token is received twice (reuse), the first use deleted it — the second use finds nothing, detects potential theft, deletes all the user’s refresh tokens (logging them out everywhere), and throws an error. This limits the damage from a stolen token to one use.

Step 4 — Rate Limiting on Auth Endpoints Prevents Brute Force

The auth router applies the authLimiter middleware (10 requests per 15 minutes per IP) to the login, register, forgot-password, and reset-password routes. This limits brute-force attacks: at 10 attempts per 15 minutes, it would take weeks to try all combinations of a 6-character password. For account takeover attempts on known emails, the attacker is also slowed to 10 guesses per 15 minutes per IP address.

Step 5 — Password Reset Invalidates All Sessions

After a successful password reset, RefreshToken.deleteMany({ user: userId }) deletes every refresh token for that user — logging them out of all devices and sessions. This is the correct response: if someone needed to reset their password, the account may have been compromised. Keeping existing sessions alive after a password reset would allow an attacker who obtained a session token to remain logged in despite the password change.

Quick Reference

Task Code
httpOnly cookie res.cookie('refreshToken', token, { httpOnly: true, secure: true, sameSite: 'strict' })
Hash token for storage crypto.createHash('sha256').update(raw).digest('hex')
Generate random token crypto.randomBytes(32).toString('hex')
Token rotation Delete old token, create new token, return new token
Clear cookie res.clearCookie('refreshToken', { path: '/api/v1/auth' })
Silent forgot-password Always return 200 — never reveal if email exists
Reset invalidates sessions await RefreshToken.deleteMany({ user: userId })

🧠 Test Yourself

An attacker steals a refresh token and uses it to obtain a new access token. The legitimate user then makes a request that triggers a token refresh. What does the server detect and what is the response?