Authentication is the process of verifying who a user is; authorisation is the process of verifying what they are allowed to do. In a MEAN Stack application, the Express API implements both using JSON Web Tokens (JWT) — cryptographically signed tokens that carry user identity — and bcrypt for storing passwords securely. Understanding how JWTs are structured, why they are stateless, and how to integrate them with Passport.js on the Express side gives you the complete server-side authentication foundation that every subsequent security feature builds on.
JWT Structure
| Part | Content | Encoded As |
|---|---|---|
| Header | Algorithm and token type: { "alg": "HS256", "typ": "JWT" } |
Base64URL |
| Payload | Claims: { "sub": "userId", "role": "user", "iat": 1234, "exp": 5678 } |
Base64URL |
| Signature | HMACSHA256(base64(header) + "." + base64(payload), secret) |
Base64URL |
Standard JWT Claims
| Claim | Meaning | Example |
|---|---|---|
sub |
Subject — the user’s ID | "64a1f2b3c8e4d5f6" |
iat |
Issued At — Unix timestamp | 1700000000 |
exp |
Expiration — Unix timestamp | 1700000900 (15 min later) |
iss |
Issuer — who created the token | "api.taskmanager.io" |
aud |
Audience — intended recipient | "taskmanager-spa" |
Custom: role |
User’s role — for RBAC | "admin" |
sub, role, and exp are usually enough.JWT_SECRET) and one for refresh tokens (REFRESH_SECRET). This allows you to invalidate all refresh tokens (by changing REFRESH_SECRET) without invalidating access tokens, or vice versa. Set access tokens to 15 minutes and refresh tokens to 7 days. Short-lived access tokens minimise the window of exposure if one is stolen; refresh tokens provide seamless session continuation.localStorage. LocalStorage is accessible by any JavaScript running on the page — an XSS vulnerability in any dependency can exfiltrate all stored tokens. Store access tokens in memory (a JavaScript variable or Angular signal) and refresh tokens in an HttpOnly; Secure; SameSite=Strict cookie. HttpOnly cookies cannot be read by JavaScript at all, even during an XSS attack.Complete Auth Implementation
// ── bcrypt password hashing ───────────────────────────────────────────────
const bcrypt = require('bcryptjs');
// Hash on registration — NEVER store plain text passwords
async function hashPassword(plainText) {
const SALT_ROUNDS = 12; // 12 rounds = ~300ms on modern hardware — good balance
return bcrypt.hash(plainText, SALT_ROUNDS);
}
// Compare on login
async function verifyPassword(plainText, storedHash) {
return bcrypt.compare(plainText, storedHash);
}
// ── JWT token generation ──────────────────────────────────────────────────
const jwt = require('jsonwebtoken');
function generateTokenPair(user) {
const payload = {
sub: user._id.toString(),
email: user.email,
role: user.role,
};
const accessToken = jwt.sign(payload, process.env.JWT_SECRET, {
expiresIn: process.env.JWT_EXPIRES_IN || '15m',
issuer: 'api.taskmanager.io',
audience: 'taskmanager-spa',
});
const refreshToken = jwt.sign(
{ sub: user._id.toString() },
process.env.REFRESH_SECRET,
{ expiresIn: '7d' }
);
return { accessToken, refreshToken };
}
// ── JWT verification middleware ───────────────────────────────────────────
function verifyAccessToken(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ message: 'No token provided' });
}
const token = authHeader.slice(7);
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET, {
issuer: 'api.taskmanager.io',
audience: 'taskmanager-spa',
});
req.user = decoded;
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ message: 'Token expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ message: 'Invalid token' });
}
}
// ── Auth routes ───────────────────────────────────────────────────────────
const router = require('express').Router();
const User = require('../models/user.model');
const asyncHandler = require('express-async-handler');
// POST /api/v1/auth/register
router.post('/register', asyncHandler(async (req, res) => {
const { name, email, password } = req.body;
const exists = await User.findOne({ email: email.toLowerCase() });
if (exists) {
return res.status(409).json({ message: 'Email already registered' });
}
const hashedPassword = await hashPassword(password);
const user = await User.create({ name, email: email.toLowerCase(), password: hashedPassword });
const { accessToken, refreshToken } = generateTokenPair(user);
// Refresh token in HttpOnly cookie — NOT accessible to JavaScript
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days in ms
path: '/api/v1/auth', // restrict cookie to auth endpoints only
});
res.status(201).json({
success: true,
data: {
accessToken,
user: { id: user._id, name: user.name, email: user.email, role: user.role },
},
});
}));
// POST /api/v1/auth/login
router.post('/login', asyncHandler(async (req, res) => {
const { email, password } = req.body;
// Include password field (select: false in schema)
const user = await User.findOne({ email: email.toLowerCase() })
.select('+password +loginAttempts +lockUntil');
if (!user) {
return res.status(401).json({ message: 'Invalid credentials' });
}
// Check account lock
if (user.lockUntil && user.lockUntil > Date.now()) {
const minutesLeft = Math.ceil((user.lockUntil - Date.now()) / 60000);
return res.status(423).json({
message: `Account locked. Try again in ${minutesLeft} minutes.`,
});
}
const isMatch = await verifyPassword(password, user.password);
if (!isMatch) {
// Increment failed attempts
await user.incrementLoginAttempts();
return res.status(401).json({ message: 'Invalid credentials' });
}
// Reset attempts on successful login
await user.resetLoginAttempts();
const { accessToken, refreshToken } = generateTokenPair(user);
res.cookie('refreshToken', refreshToken, {
httpOnly: true, secure: process.env.NODE_ENV === 'production',
sameSite: 'strict', maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/api/v1/auth',
});
res.json({
success: true,
data: { accessToken, user: { id: user._id, name: user.name, email: user.email, role: user.role } },
});
}));
// POST /api/v1/auth/refresh
router.post('/refresh', asyncHandler(async (req, res) => {
const token = req.cookies.refreshToken;
if (!token) return res.status(401).json({ message: 'No refresh token' });
let decoded;
try {
decoded = jwt.verify(token, process.env.REFRESH_SECRET);
} catch {
return res.status(401).json({ message: 'Invalid refresh token' });
}
const user = await User.findById(decoded.sub);
if (!user || !user.isActive) {
return res.status(401).json({ message: 'User not found or inactive' });
}
const { accessToken, refreshToken: newRefresh } = generateTokenPair(user);
// Rotate: issue a new refresh token and invalidate the old one
res.cookie('refreshToken', newRefresh, {
httpOnly: true, secure: process.env.NODE_ENV === 'production',
sameSite: 'strict', maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/api/v1/auth',
});
res.json({ success: true, data: { accessToken } });
}));
// POST /api/v1/auth/logout
router.post('/logout', (req, res) => {
res.clearCookie('refreshToken', { path: '/api/v1/auth' });
res.json({ success: true, message: 'Logged out' });
});
module.exports = router;
How It Works
Step 1 — bcrypt Protects Passwords with Adaptive Hashing
bcrypt applies a salt (random data appended to the password before hashing) and runs the hashing function multiple times determined by the cost factor (12 rounds = 2^12 iterations). The output includes the salt and cost factor, making each hash unique even for identical passwords. The salt prevents rainbow table attacks. The cost factor means brute-forcing is computationally expensive — 12 rounds takes ~300ms, meaning an attacker can try only ~3 passwords per second per CPU core.
Step 2 — JWT Signature Prevents Tampering
The signature is computed over the header and payload using the server’s secret. If an attacker modifies any part of the token (changing role: "user" to role: "admin"), the signature check in jwt.verify() fails because the signature was computed over the original payload. The server never needs to store issued tokens — it just verifies the signature on each request. This is the “stateless” property of JWTs.
Step 3 — Short-Lived Access Tokens Limit Exposure
An access token valid for 15 minutes means that even if it is stolen (through a network intercept, log leak, or bug), the attacker only has a 15-minute window. After expiry, jwt.verify() throws TokenExpiredError. The Angular interceptor catches 401 responses and calls the refresh endpoint to get a new access token. This refresh flow is transparent to the user — they never see a login prompt unless the refresh token is also expired or revoked.
Step 4 — HttpOnly Cookies Make Refresh Tokens Inaccessible to JavaScript
Setting httpOnly: true on the refresh token cookie means no JavaScript — not Angular, not any library, not an XSS payload — can read it. The browser sends it automatically with requests to the matching path (/api/v1/auth), but document.cookie will never include it. Combined with SameSite: strict (preventing CSRF) and Secure: true (HTTPS only), this is the most secure token storage available in browsers.
Step 5 — Refresh Token Rotation Limits Stolen Token Damage
Every time the client uses a refresh token to get a new access token, the server issues a new refresh token and invalidates the old one. If a refresh token is stolen, the attacker can only use it once — the legitimate client will then fail to refresh (old token rejected) and be forced to log in again, at which point the stolen token is useless. This is refresh token rotation — it limits the window of exposure for stolen refresh tokens.
Common Mistakes
Mistake 1 — Storing tokens in localStorage
❌ Wrong — XSS can steal tokens from localStorage:
localStorage.setItem('accessToken', accessToken);
// XSS: document.cookie is blocked but localStorage is accessible to ANY script!
✅ Correct — store access token in memory, refresh token in HttpOnly cookie:
// Server: res.cookie('refreshToken', token, { httpOnly: true, ... })
// Angular: this.authStore.setAccessToken(token) — in a signal, not localStorage
Mistake 2 — Including sensitive data in JWT payload
❌ Wrong — password hash visible to anyone who decodes the token:
jwt.sign({ sub: user._id, password: user.password }, secret); // NEVER!
✅ Correct — minimal, non-sensitive claims only:
jwt.sign({ sub: user._id, role: user.role }, secret, { expiresIn: '15m' });
Mistake 3 — Low bcrypt cost factor for fast hashing
❌ Wrong — cost 4 is too fast; passwords easily brute-forced:
bcrypt.hash(password, 4); // ~1ms — millions of attempts per second!
✅ Correct — cost 12 as minimum for 2024 hardware:
bcrypt.hash(password, 12); // ~300ms — 3 attempts/second/core
Quick Reference
| Task | Code |
|---|---|
| Hash password | await bcrypt.hash(plain, 12) |
| Verify password | await bcrypt.compare(plain, hash) |
| Sign access token | jwt.sign({ sub, role }, JWT_SECRET, { expiresIn: '15m' }) |
| Sign refresh token | jwt.sign({ sub }, REFRESH_SECRET, { expiresIn: '7d' }) |
| Verify token | jwt.verify(token, JWT_SECRET) — throws on invalid/expired |
| HttpOnly cookie | res.cookie('refreshToken', token, { httpOnly: true, secure: true, sameSite: 'strict' }) |
| Clear cookie | res.clearCookie('refreshToken', { path: '/api/v1/auth' }) |
| Protect route | router.use(verifyAccessToken) |