Authentication

Express.js Intermediate — Authentication

Express.js Intermediate/Advanced

Chapter 1 of 8  ·  Authentication

Authentication

Authentication is the gate between anonymous requests and your application's data. The central question is: how does the server know — on every request — who is making it? There are two fundamentally different answers: sessions (the server remembers you) and tokens (you carry proof of identity with you). JWT-based authentication is now the dominant approach for REST APIs. Getting it right means understanding what a JWT actually is, why the access/refresh split exists, and where each token should live on the client.

Sessions vs Tokens

Session-Based (Stateful)

Server stores session state. Client holds only a random session ID in a cookie.

Client → POST /login → Server Server creates session, stores it (DB/Redis) Server → Set-Cookie: sessionId=abc123 Client sends cookie on every request Server looks up session in store on every request

Pro: Instant revocation (delete the session row).
Con: Needs a shared session store (Redis) for multiple server instances.

Token-Based (Stateless)

Server stores nothing. Client holds a signed token containing identity + expiry.

Client → POST /login → Server Server creates signed JWT, stores nothing Server → { accessToken, refreshToken } Client sends token: Authorization: Bearer <jwt> Server verifies signature — no DB lookup needed

Pro: Stateless — scales horizontally without a shared store.
Con: Can't revoke a token until it expires (need a denylist to force-logout).

What Is a JWT?

A JSON Web Token is three base64url-encoded JSON objects joined by dots: header.payload.signature. The header and payload are just base64 — anyone can decode them. The signature is a cryptographic MAC: it proves the token was issued by someone who knows the secret key, and that the header and payload have not been tampered with.

Header

Algorithm and token type.

{ "alg": "HS256", "typ": "JWT" }

Payload (Claims)

Identity + metadata. Readable by anyone.

{ "sub": "42", "role": "admin", "iat": 1700000000, "exp": 1700000900 }

Signature

Tamper-proof seal. Requires the secret key.

HMAC-SHA256( base64(header) + "." + base64(payload), SECRET )
// npm install jsonwebtoken const jwt = require('jsonwebtoken'); // Sign a token — second arg is the secret, NEVER commit it const token = jwt.sign( { sub: '42', role: 'admin' }, // payload (claims) process.env.JWT_SECRET, // secret key { expiresIn: '15m', algorithm: 'HS256' } ); // → "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIs..." // Verify a token — throws if expired, tampered, or wrong secret try { const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'], // always specify — prevents alg:none attack }); console.log(decoded.sub); // '42' console.log(decoded.role); // 'admin' console.log(decoded.iat); // issued-at timestamp (added automatically) console.log(decoded.exp); // expiry timestamp (added automatically) } catch (err) { // err.name === 'TokenExpiredError' | 'JsonWebTokenError' | 'NotBeforeError' console.error(err.name, err.message); }

⚠ Signed ≠ Encrypted

A JWT payload is base64-encoded, not encrypted. Anyone who intercepts the token can read it by pasting it into jwt.io. Never put sensitive data in the payload — passwords, full credit card numbers, PII. The signature only proves authenticity (who signed it), not confidentiality (who can read it).

Access Tokens & Refresh Tokens

A single long-lived token creates a dilemma: if it's stolen, the attacker has it until it expires. If you make it expire in 5 minutes, users log out constantly. The access/refresh split resolves this.

TokenLifetimeStored wherePurpose
Access token Short (5–15 min) Memory (JS variable) or Authorization header Authenticate API calls. Short lifetime limits damage if stolen.
Refresh token Long (7–30 days) httpOnly Secure cookie Used only to get a new access token. Never sent to API routes.

The Token Flow

── Login ─────────────────────────────────────────────────────────────── Client ──POST /auth/login { email, password }──────────────► Server verify credentials, generate access + refresh tokens Client ◄──{ accessToken } Set-Cookie: refreshToken=... (httpOnly)── Server ── Authenticated request ──────────────────────────────────────────────── Client ──Authorization: Bearer <accessToken>──────────────────► Server verifies signature in memory — no DB lookup Client ◄──200 { data }──────────────────────────────────────── Server ── Access token expires → silent refresh ──────────────────────────────── Client ──POST /auth/refresh (cookie sent automatically)──────► Server verify refresh token, issue NEW access token + NEW refresh token invalidate old refresh token (rotation) Client ◄──{ accessToken } Set-Cookie: refreshToken=... (new)─── Server ── Logout ─────────────────────────────────────────────────────────────── Client ──POST /auth/logout (cookie sent automatically)───────► Server invalidate refresh token in DB, clear cookie Client ◄──204 No Content Set-Cookie: refreshToken=; Max-Age=0─ Server

Implementation

Token Helper

// src/utils/tokens.js const jwt = require('jsonwebtoken'); const crypto = require('node:crypto'); const ACCESS_SECRET = process.env.JWT_SECRET; const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET; const ALG = ['HS256']; function signAccess(payload) { return jwt.sign(payload, ACCESS_SECRET, { expiresIn: '15m', algorithm: 'HS256' }); } function signRefresh(payload) { return jwt.sign(payload, REFRESH_SECRET, { expiresIn: '7d', algorithm: 'HS256' }); } function verifyAccess(token) { return jwt.verify(token, ACCESS_SECRET, { algorithms: ALG }); } function verifyRefresh(token) { return jwt.verify(token, REFRESH_SECRET, { algorithms: ALG }); } // Opaque token for refresh — random bytes, stored hashed in DB function generateRefreshId() { return crypto.randomBytes(40).toString('hex'); } module.exports = { signAccess, signRefresh, verifyAccess, verifyRefresh, generateRefreshId };

Auth Routes

// src/routes/v1/auth.js require('express-async-errors'); const router = require('express').Router(); const bcrypt = require('bcrypt'); const { signAccess, signRefresh, verifyRefresh } = require('../../utils/tokens'); const createError = require('../../utils/createError'); // Refresh token denylist — in production: a DB table or Redis set const revokedTokens = new Set(); const COOKIE_OPTS = { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict', maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days in ms path: '/auth', // cookie only sent to /auth/* routes }; // POST /auth/login router.post('/login', async (req, res) => { const { email, password } = req.body ?? {}; if (!email || !password) throw createError(400, 'email and password required'); const user = await db.findByEmail(email); if (!user) throw createError(401, 'Invalid credentials'); const ok = await bcrypt.compare(password, user.passwordHash); if (!ok) throw createError(401, 'Invalid credentials'); const accessToken = signAccess({ sub: user.id, role: user.role }); const refreshToken = signRefresh({ sub: user.id }); res.cookie('refreshToken', refreshToken, COOKIE_OPTS) .json({ accessToken }); }); // POST /auth/refresh — silent token rotation router.post('/refresh', async (req, res) => { const token = req.cookies.refreshToken; if (!token) throw createError(401, 'No refresh token'); if (revokedTokens.has(token)) throw createError(401, 'Refresh token revoked'); const payload = verifyRefresh(token); // throws if expired/tampered // Rotation: revoke old token, issue a new pair revokedTokens.add(token); const newAccess = signAccess({ sub: payload.sub, role: payload.role }); const newRefresh = signRefresh({ sub: payload.sub }); res.cookie('refreshToken', newRefresh, COOKIE_OPTS) .json({ accessToken: newAccess }); }); // POST /auth/logout router.post('/logout', (req, res) => { const token = req.cookies.refreshToken; if (token) revokedTokens.add(token); res.clearCookie('refreshToken', { path: '/auth' }) .status(204).end(); }); module.exports = router;

Authentication Middleware

// src/middleware/authenticate.js const { verifyAccess } = require('../utils/tokens'); const createError = require('../utils/createError'); function authenticate(req, res, next) { const header = req.get('Authorization'); if (!header?.startsWith('Bearer ')) { return next(createError(401, 'Bearer token required')); } const token = header.slice(7); try { req.user = verifyAccess(token); // attaches { sub, role, iat, exp } next(); } catch (err) { const message = err.name === 'TokenExpiredError' ? 'Token expired' : 'Invalid token'; next(createError(401, message)); } } // Optional: role-based guard that sits after authenticate() const requireRole = (...roles) => (req, res, next) => roles.includes(req.user?.role) ? next() : next(createError(403, 'Insufficient permissions')); module.exports = { authenticate, requireRole }; // Usage in a router const { authenticate, requireRole } = require('../middleware/authenticate'); router.get('/profile', authenticate, controller.getProfile); router.delete('/users/:id', authenticate, requireRole('admin'), controller.deleteUser);

Token Storage Security

StorageXSS riskCSRF riskVerdict
JS memory (variable) Low None Best for access tokens in SPAs — lost on page refresh, which is fine at 15 min lifetime.
localStorage High None Avoid for auth tokens — any injected script can read it.
sessionStorage High None Same XSS risk as localStorage; cleared on tab close but still accessible to scripts.
httpOnly cookie None Yes (needs SameSite or CSRF token) Best for refresh tokens — JavaScript cannot read it. Use SameSite: 'strict' to eliminate CSRF risk on same-origin requests.

🔒 Refresh Token Rotation

Every time a refresh token is used, immediately invalidate it and issue a new one. If a stolen refresh token is used by an attacker, the legitimate user's next refresh will find their token already revoked — this detects the theft. Without rotation, a stolen refresh token is valid for its full 30-day lifetime with no way to detect misuse.

Store refresh tokens in a DB table (id, user_id, token_hash, expires_at, revoked). Hash the token before storing — the DB never holds the raw value.

⚠ The alg:none Attack

JWTs carry the algorithm in the header. Early libraries trusted this header — so an attacker could set "alg": "none" and send an unsigned token that would verify successfully. Always pass { algorithms: ['HS256'] } to jwt.verify(). Modern libraries default to rejecting alg:none, but being explicit costs nothing and is a habit worth building.

Quick Reference

OperationAPI
jwt.sign(payload, secret, options)Creates a signed token. Options: expiresIn, algorithm, issuer, audience.
jwt.verify(token, secret, options)Validates and decodes. Throws TokenExpiredError, JsonWebTokenError, NotBeforeError.
jwt.decode(token)Decodes without verifying. Use only for logging/debugging — never for authentication decisions.
err.name === 'TokenExpiredError'Token signature was valid but exp is in the past. Client should refresh.
err.name === 'JsonWebTokenError'Signature mismatch, malformed token, or wrong algorithm. Treat as 401.
Authorization: Bearer <token>Standard header for sending access tokens. Parse with req.get('Authorization').slice(7).

Coding Challenges

Challenge 1 — Login & Protected Route

Build an auth system with three routes: POST /auth/login (accept { email, password }, validate against a hardcoded users map with pre-hashed passwords using bcrypt; return { accessToken } and set a refreshToken httpOnly cookie); GET /me (protected — validate the Bearer access token, return { id, email, role } from the decoded payload); POST /auth/logout (clear the refresh cookie, 204). Use two separate env-var secrets for access and refresh tokens. Write supertest tests: 400 for missing credentials, 401 for wrong password, successful login returns accessToken and sets Set-Cookie, /me without token returns 401, /me with valid token returns 200, /me with expired token returns 401 with "Token expired" message (create an already-expired token in the test by signing with expiresIn: -1).

View sample solution ↗

Challenge 2 — Refresh Token Rotation

Extend the auth system with a POST /auth/refresh route that reads the refreshToken cookie, verifies it with the refresh secret, revokes the old token (in-memory Set), issues a new access token and a new refresh token (rotation), and sets the new cookie. Add a GET /protected route that requires a valid access token and returns a confirmation. Write supertest tests using supertest.agent() to maintain cookies: full login → protected route works → simulate access token expiry → refresh returns a new access token → new access token works on protected route → old refresh token is now revoked (second refresh with old cookie returns 401) → logout clears cookie → protected route returns 401.

View sample solution ↗

Challenge 3 — Role-Based Access Control

Build a multi-role API with three roles: user, editor, admin. Write an authenticate middleware that extracts and verifies the Bearer token and attaches req.user. Write a requireRole(...roles) middleware factory that checks req.user.role — 401 if not authenticated (missing req.user), 403 if authenticated but wrong role. Routes: GET /public (no auth), GET /dashboard (any authenticated user), POST /articles (editor or admin only), DELETE /articles/:id (admin only). Generate test tokens directly (skip the login flow) by calling jwt.sign() in the test file. Write supertest tests: public route works unauthenticated, dashboard returns 401 with no token, dashboard returns 200 for user/editor/admin tokens, articles POST returns 403 for user-role token and 201 for editor/admin, articles DELETE returns 403 for user and editor, 204 for admin.

View sample solution ↗