JWT Authentication and bcrypt — Server-Side Auth Foundations

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"
Note: JWT payloads are Base64URL encoded — not encrypted. Anyone who holds a JWT can decode and read the payload without the secret. Never put sensitive information (passwords, credit card numbers, PII beyond necessary) in a JWT payload. The signature only proves the token was issued by someone who held the secret — it does not hide the payload contents. Use HTTPS to prevent interception, and keep payloads minimal — just sub, role, and exp are usually enough.
Tip: Use two separate JWT secrets — one for access tokens (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.
Warning: Never store JWT access tokens in 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)

🧠 Test Yourself

A JWT access token has role: "user" in its payload. An attacker intercepts the token and changes the payload to role: "admin". Why does this attack fail?