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 |
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.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
Step 1 — httpOnly Cookie Restricts Refresh Token to HTTP Layer
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 }) |