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.
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.
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 )
⚠ 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.
| Token | Lifetime | Stored where | Purpose |
|---|---|---|---|
| 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
Implementation
Token Helper
Auth Routes
Authentication Middleware
Token Storage Security
| Storage | XSS risk | CSRF risk | Verdict |
|---|---|---|---|
| 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
| Operation | API |
|---|---|
| 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).
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.
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.