JWT Authentication
Access Tokens, Refresh Tokens, and Where to Store Them
LinkedIn Hook
"Where do you store your JWT — localStorage or a cookie?"
If you answered "localStorage" without hesitation, congratulations: you just handed every XSS attacker on your site a master key to your users' accounts. If you answered "httpOnly cookie" without thinking, you may have walked straight into a CSRF trap.
JWT authentication looks deceptively simple. Sign a token, send it to the client, verify it on every request. But the real interview questions are never about the signing — they are about what happens after. How do you revoke a token before it expires? How do you rotate a refresh token without locking out legitimate users? Why is
localStoragea security disaster, and why ishttpOnlynot a silver bullet?In Lesson 8.1, I break down JWT from the ground up — the three-part structure, HS256 vs RS256, the access + refresh token pattern, storage tradeoffs, and the revocation strategies senior engineers actually use in production.
Read the full lesson -> [link]
#NodeJS #JWT #Authentication #WebSecurity #BackendDevelopment #InterviewPrep
What You'll Learn
- What a JWT actually is — the three-part
header.payload.signaturestructure and base64url encoding - The difference between symmetric (HS256) and asymmetric (RS256) signing — and when to use each
- How to sign and verify tokens with the
jsonwebtokenlibrary - The access token + refresh token pattern — why one short-lived token is not enough
- Where to store tokens on the client —
httpOnlycookies vslocalStorageand the XSS / CSRF tradeoff - How the
expclaim works and why expiration alone is not revocation - Revocation strategies — token blocklists, short expiry windows, and refresh token rotation
The Concert Wristband Analogy — Why JWTs Work
Imagine you arrive at a three-day music festival. At the gate, security checks your ID and credit card, confirms you bought a ticket, and snaps a tamper-evident wristband onto your wrist. The wristband has your name, your ticket tier (VIP, General, Backstage), and an expiration date printed on it. It also has a special holographic seal that only the festival organizers know how to produce.
For the next three days, you can walk up to any stage, any food stand, any bathroom, and any backstage door. The bouncer there doesn't call the central database. They don't ask for your ID again. They look at your wristband, check the holographic seal is genuine, glance at the tier, and wave you through. The wristband is self-contained proof that you are allowed to be there.
That is exactly what a JWT is. The user logs in once with their password. The server checks the database, confirms the credentials, and issues a signed token containing the user's ID, role, and expiration. For every subsequent request, the server doesn't query the database — it just verifies the signature and reads the claims. The token carries its own proof of authenticity.
Now, the wristband analogy also reveals JWT's biggest weakness: what happens if a wristband is stolen? A bouncer has no way to know it was stolen. The hologram is still valid. The expiration date hasn't passed. The thief walks right in. The only way to invalidate a wristband is to either wait for it to expire, or to give every bouncer a list of revoked wristband IDs to check against — which defeats the whole point of having self-contained tokens.
This is why production JWT systems use short-lived access tokens (15 minutes) paired with long-lived refresh tokens (7-30 days). Even if a wristband is stolen, it expires in 15 minutes — and the refresh token, which is the only way to get a new one, is stored in a much safer place.
+---------------------------------------------------------------+
| ANATOMY OF A JWT |
+---------------------------------------------------------------+
| |
| eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 |
| . |
| eyJzdWIiOiIxMjM0IiwibmFtZSI6IkFsaWNlIiwiZXhwIjoxNjk5fQ |
| . |
| SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c |
| |
| +----------+ +-----------------+ +------------------------+ |
| | HEADER | | PAYLOAD | | SIGNATURE | |
| +----------+ +-----------------+ +------------------------+ |
| | | | |
| v v v |
| { "alg": { "sub": "1234", HMAC-SHA256( |
| "HS256", "name": "Alice", base64url(header) |
| "typ": "role": "admin", + "." |
| "JWT" } "exp": 1699999999 } + base64url(payload), |
| secret ) |
| |
| All three parts are base64url-encoded (URL-safe base64, |
| no padding, '+' -> '-', '/' -> '_'). |
| |
| The payload is NOT encrypted. It is signed. |
| Anyone can read it. Only the server can forge it. |
| |
+---------------------------------------------------------------+
Napkin AI Visual Prompt: "Dark gradient (#0a1a0a -> #0d2e16). A long horizontal JWT token broken into three colored segments: green (#68a063) HEADER, amber (#ffb020) PAYLOAD, white SIGNATURE — separated by glowing dots. Below each segment, the decoded JSON content in white monospace. A red 'NOT ENCRYPTED' label points at the payload with an arrow. A green checkmark labeled 'TAMPER-PROOF' points at the signature."
What a JWT Actually Is
A JSON Web Token (JWT, pronounced "jot") is a compact, URL-safe string that represents a set of claims. It consists of three parts separated by dots: header.payload.signature. Each part is base64url-encoded — a URL-safe variant of base64 where + becomes -, / becomes _, and trailing = padding is removed.
The header declares the signing algorithm and token type:
{
"alg": "HS256",
"typ": "JWT"
}
The payload contains the claims — statements about the user and metadata about the token. Some claims are standardized (registered claims like sub, exp, iat), others are application-specific:
{
"sub": "user_1234",
"name": "Alice Johnson",
"role": "admin",
"iat": 1699900000,
"exp": 1699900900
}
The signature is the cryptographic proof. It is computed by hashing the base64url-encoded header and payload together with a secret (or private key). If anyone modifies the payload — say, changing "role": "user" to "role": "admin" — the signature no longer matches, and verification fails.
The critical insight: the payload is encoded, not encrypted. Anyone who intercepts a JWT can paste it into jwt.io and read every claim. Never put passwords, social security numbers, or any sensitive data in a JWT payload. The signature only guarantees that the claims have not been tampered with — it does not hide them.
HS256 vs RS256 — Symmetric vs Asymmetric Signing
The alg header tells the verifier which algorithm to use. The two most common are HS256 and RS256.
HS256 (HMAC-SHA256) is symmetric. The same secret key is used to both sign and verify the token. This is simple and fast, but it means any service that needs to verify tokens must also possess the signing secret — which means any compromised verifier can forge tokens.
RS256 (RSA-SHA256) is asymmetric. A private key signs tokens, and a corresponding public key verifies them. The signing service is the only one that holds the private key. Other services (microservices, third parties, mobile apps) can verify tokens with the public key without ever being able to forge them.
+---------------------------------------------------------------+
| HS256 vs RS256 |
+---------------------------------------------------------------+
| |
| HS256 (Symmetric): |
| |
| [Auth Service] --secret--> [API Service] |
| | | |
| signs verifies |
| (with secret) (with same secret) |
| |
| One key. Fast. Simple. |
| PROBLEM: every verifier can also forge. |
| |
| RS256 (Asymmetric): |
| |
| [Auth Service] ----public key----> [API Service] |
| | | |
| signs verifies |
| (private key, (public key, |
| secret) shareable) |
| |
| Two keys. Slower. Distributable. |
| SAFER: verifiers cannot forge new tokens. |
| |
+---------------------------------------------------------------+
Rule of thumb: Use HS256 for monoliths where one service signs and verifies its own tokens. Use RS256 for microservices, public APIs, OAuth providers, or anywhere multiple parties need to verify tokens issued by one trusted source.
Signing and Verifying with jsonwebtoken
The jsonwebtoken package is the de facto Node.js library for JWTs. Install it with npm install jsonwebtoken.
Example 1 — Sign and Verify (HS256)
// auth/jwt.js
// The classic JWT signing and verification flow with HS256.
const jwt = require('jsonwebtoken');
// In production this MUST come from an environment variable.
// Use a long random string (32+ bytes from crypto.randomBytes).
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-do-not-use';
// Sign a token containing user claims.
function signToken(user) {
// The first argument is the payload (the claims).
// 'sub' (subject) is the standardized claim for the user identifier.
const payload = {
sub: user.id,
email: user.email,
role: user.role,
};
// jsonwebtoken adds 'iat' (issued at) automatically.
// 'expiresIn' is converted into the 'exp' claim.
return jwt.sign(payload, JWT_SECRET, {
algorithm: 'HS256',
expiresIn: '15m', // Short-lived access token
issuer: 'api.myapp.com', // 'iss' claim — who issued this
audience: 'myapp-clients', // 'aud' claim — who it's intended for
});
}
// Verify a token and return its decoded payload.
// Throws if the signature is invalid, the token is expired,
// or the issuer/audience do not match.
function verifyToken(token) {
return jwt.verify(token, JWT_SECRET, {
algorithms: ['HS256'], // Whitelist algorithms — never trust 'alg'
issuer: 'api.myapp.com',
audience: 'myapp-clients',
});
}
module.exports = { signToken, verifyToken };
Critical security note: Always pass an explicit algorithms array to verify. Without it, an attacker can craft a token with "alg": "none" and bypass signature verification entirely. This is one of the most famous JWT vulnerabilities (CVE-2015-9235).
The Access Token + Refresh Token Pattern
A single long-lived JWT is a security disaster waiting to happen. If a token with a 30-day expiry is stolen, the attacker has 30 days of full access. But a token with a 5-minute expiry forces the user to log in every 5 minutes — terrible UX.
The solution is two tokens:
- Access token — short-lived (5-15 minutes), sent with every API request, contains user claims, verified statelessly.
- Refresh token — long-lived (7-30 days), stored more securely, used only to get new access tokens, often tracked in a database so it can be revoked.
+---------------------------------------------------------------+
| ACCESS + REFRESH TOKEN FLOW |
+---------------------------------------------------------------+
| |
| [Client] [Auth Server] |
| | | |
| | POST /login (email, password) | |
| |-------------------------------------->| |
| | | |
| | { accessToken, refreshToken } | |
| |<--------------------------------------| |
| | | |
| | GET /api/data | |
| | Authorization: Bearer <accessToken> | |
| |-------------------------------------->| |
| | | |
| | 200 OK { data } | |
| |<--------------------------------------| |
| | | |
| | ... 15 minutes pass, access expires ... |
| | | |
| | GET /api/data (with old token) | |
| |-------------------------------------->| |
| | | |
| | 401 Unauthorized | |
| |<--------------------------------------| |
| | | |
| | POST /refresh (refreshToken) | |
| |-------------------------------------->| |
| | | |
| | { accessToken, NEW refreshToken } | |
| |<--------------------------------------| |
| | | |
| | Retry GET /api/data with new token | |
| |-------------------------------------->| |
| | | |
+---------------------------------------------------------------+
Example 2 — Issuing Both Tokens at Login
// auth/login.js
// Issuing access + refresh tokens on successful login.
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const bcrypt = require('bcrypt');
const db = require('../db');
const ACCESS_SECRET = process.env.ACCESS_SECRET;
const REFRESH_SECRET = process.env.REFRESH_SECRET;
async function login(req, res) {
const { email, password } = req.body;
// Look up the user and verify the password hash.
const user = await db.users.findOne({ email });
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
// Generic error message — never reveal which field was wrong.
return res.status(401).json({ error: 'Invalid credentials' });
}
// Short-lived access token — carries user claims.
const accessToken = jwt.sign(
{ sub: user.id, role: user.role },
ACCESS_SECRET,
{ algorithm: 'HS256', expiresIn: '15m' }
);
// Long-lived refresh token — minimal claims, includes a unique 'jti'
// (JWT ID) so we can track and revoke specific refresh tokens.
const jti = crypto.randomBytes(16).toString('hex');
const refreshToken = jwt.sign(
{ sub: user.id, jti },
REFRESH_SECRET,
{ algorithm: 'HS256', expiresIn: '7d' }
);
// Store the refresh token's jti in the database so we can revoke it.
// We store a hash so a database leak does not expose valid tokens.
await db.refreshTokens.insert({
jti,
userId: user.id,
tokenHash: crypto.createHash('sha256').update(refreshToken).digest('hex'),
createdAt: new Date(),
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
});
// Send the access token in the JSON body, and the refresh token
// in an httpOnly cookie (see the cookie example below).
res
.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/auth/refresh',
maxAge: 7 * 24 * 60 * 60 * 1000,
})
.json({ accessToken });
}
module.exports = { login };
Protecting Routes with Middleware
Once tokens are issued, every protected endpoint needs to verify them. Express middleware is the standard pattern.
Example 3 — Auth Middleware
// middleware/requireAuth.js
// Express middleware that verifies the access token on protected routes.
const jwt = require('jsonwebtoken');
const ACCESS_SECRET = process.env.ACCESS_SECRET;
function requireAuth(req, res, next) {
// The access token is sent in the Authorization header as:
// Authorization: Bearer <token>
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing or malformed token' });
}
// Strip the 'Bearer ' prefix to get the raw token.
const token = authHeader.slice(7);
try {
// Verify and decode the token. Always whitelist algorithms.
const payload = jwt.verify(token, ACCESS_SECRET, {
algorithms: ['HS256'],
});
// Attach the decoded user info to the request for downstream handlers.
req.user = { id: payload.sub, role: payload.role };
next();
} catch (err) {
// jwt.verify throws TokenExpiredError, JsonWebTokenError, etc.
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired', code: 'EXPIRED' });
}
return res.status(401).json({ error: 'Invalid token' });
}
}
// Optional role-based guard built on top.
function requireRole(role) {
return (req, res, next) => {
if (req.user?.role !== role) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
module.exports = { requireAuth, requireRole };
Usage:
// routes/admin.js
const express = require('express');
const { requireAuth, requireRole } = require('../middleware/requireAuth');
const router = express.Router();
// Any authenticated user can read their profile.
router.get('/profile', requireAuth, (req, res) => {
res.json({ userId: req.user.id });
});
// Only admins can hit this endpoint.
router.delete('/users/:id', requireAuth, requireRole('admin'), (req, res) => {
// ... delete logic
});
Where to Store Tokens — The XSS vs CSRF Tradeoff
This is the question every senior engineer should be able to answer cold.
localStorage (or sessionStorage):
- Easy to use from JavaScript.
- Vulnerable to XSS. Any injected script — from a compromised npm package, a third-party widget, or a stored XSS bug — can read
localStorageand steal every token in it. Game over. - Not sent automatically with requests, so no CSRF risk.
httpOnly cookie:
- Inaccessible to JavaScript. An XSS attacker cannot read it.
- Sent automatically with every request to your domain — which means the browser will also send it on cross-site requests triggered by malicious pages. This is CSRF.
- Mitigated with
SameSite=Strict(orLax) and CSRF tokens for state-changing requests.
+---------------------------------------------------------------+
| TOKEN STORAGE TRADEOFFS |
+---------------------------------------------------------------+
| |
| | localStorage | httpOnly cookie |
| -----------------+----------------+-------------------- |
| XSS readable | YES (BAD) | NO |
| CSRF risk | NO | YES (mitigate w/ SameSite|
| | | + CSRF token) |
| Auto-sent | NO | YES |
| JS accessible | YES | NO |
| Mobile-friendly | YES | Awkward |
| |
| RECOMMENDED PATTERN: |
| Access token -> memory (JS variable, NOT localStorage) |
| Refresh token -> httpOnly + Secure + SameSite=Strict cookie |
| scoped to /auth/refresh |
| |
+---------------------------------------------------------------+
The modern best practice: keep the access token in memory only (a JavaScript variable that disappears on page reload), and store the refresh token in an httpOnly cookie scoped to the /auth/refresh endpoint. On page reload, the client calls /auth/refresh to get a fresh access token.
Example 4 — Setting an httpOnly Cookie Securely
// Setting a refresh token cookie with all the right flags.
res.cookie('refreshToken', refreshToken, {
// Inaccessible to document.cookie / JavaScript.
httpOnly: true,
// Only sent over HTTPS. Always true in production.
secure: true,
// Block the cookie from being sent on cross-site requests.
// 'strict' is the safest. 'lax' allows top-level navigations.
sameSite: 'strict',
// Restrict the cookie to the refresh endpoint only.
// It will NOT be sent with /api/data requests, eliminating CSRF
// exposure on the rest of the API.
path: '/auth/refresh',
// 7 days, matching the refresh token expiry.
maxAge: 7 * 24 * 60 * 60 * 1000,
// Optionally lock to your apex + subdomain.
// domain: '.myapp.com',
});
Expiration and the exp Claim
The exp claim is a Unix timestamp (seconds since epoch) at which the token becomes invalid. The jsonwebtoken library checks it automatically during verify and throws TokenExpiredError when it has passed. There is also iat (issued at) and the optional nbf (not before) claim that prevents a token from being used before a certain time.
Why short expiry matters: Expiration is the only revocation mechanism a stateless JWT has. A 15-minute access token means a stolen token has at most 15 minutes of damage. A 24-hour token has 24 hours. Always pick the shortest expiry your UX can tolerate.
Revocation Strategies
JWTs are stateless by design — that is their selling point and their curse. There is no central database to "log out" of. Once issued, a JWT is valid until it expires. Real systems handle this in three ways:
1. Short access token expiry. The simplest strategy. Make access tokens so short-lived (5-15 minutes) that revocation is rarely needed — by the time you notice a token is stolen, it has nearly expired anyway.
2. Token blocklist (denylist).
Maintain a Redis set of revoked token IDs (jti claims). On every verify, check the blocklist after verifying the signature. This restores the database round-trip you were trying to avoid, but you only check it for sensitive endpoints, and the entry expires from Redis when the token would have expired anyway.
3. Refresh token rotation. Every time the client uses a refresh token, the server issues a new refresh token and invalidates the old one. If an attacker steals a refresh token and uses it before the legitimate user, the legitimate user's next refresh attempt will fail — and you can detect the theft and revoke the entire token family.
Example 5 — Refresh Endpoint with Rotation
// auth/refresh.js
// Refresh endpoint that rotates the refresh token on every use.
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const db = require('../db');
const ACCESS_SECRET = process.env.ACCESS_SECRET;
const REFRESH_SECRET = process.env.REFRESH_SECRET;
async function refresh(req, res) {
// The refresh token comes from the httpOnly cookie, never the body.
const oldToken = req.cookies.refreshToken;
if (!oldToken) {
return res.status(401).json({ error: 'No refresh token' });
}
let payload;
try {
// Verify the signature and expiration.
payload = jwt.verify(oldToken, REFRESH_SECRET, { algorithms: ['HS256'] });
} catch {
return res.status(401).json({ error: 'Invalid refresh token' });
}
// Look up the stored token by its jti.
const stored = await db.refreshTokens.findOne({ jti: payload.jti });
// If the jti is not in the DB, the token was already rotated or revoked.
// This is the THEFT DETECTION signal — invalidate the entire family.
if (!stored) {
await db.refreshTokens.deleteMany({ userId: payload.sub });
return res.status(401).json({ error: 'Refresh token reuse detected' });
}
// Confirm the token hash matches what we stored.
const hash = crypto.createHash('sha256').update(oldToken).digest('hex');
if (hash !== stored.tokenHash) {
return res.status(401).json({ error: 'Token mismatch' });
}
// Delete the old refresh token — it can never be used again.
await db.refreshTokens.deleteOne({ jti: payload.jti });
// Issue a brand new access token + refresh token pair.
const user = await db.users.findById(payload.sub);
const newAccess = jwt.sign(
{ sub: user.id, role: user.role },
ACCESS_SECRET,
{ algorithm: 'HS256', expiresIn: '15m' }
);
const newJti = crypto.randomBytes(16).toString('hex');
const newRefresh = jwt.sign(
{ sub: user.id, jti: newJti },
REFRESH_SECRET,
{ algorithm: 'HS256', expiresIn: '7d' }
);
await db.refreshTokens.insert({
jti: newJti,
userId: user.id,
tokenHash: crypto.createHash('sha256').update(newRefresh).digest('hex'),
createdAt: new Date(),
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
});
res
.cookie('refreshToken', newRefresh, {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/auth/refresh',
maxAge: 7 * 24 * 60 * 60 * 1000,
})
.json({ accessToken: newAccess });
}
module.exports = { refresh };
Common Mistakes
1. Storing JWTs in localStorage "because it's easy".
Any XSS bug — anywhere on your domain, in any third-party script you ever load — can read every token in localStorage and exfiltrate it. There is no defense once an attacker can run JavaScript on your origin. Use httpOnly cookies for refresh tokens and keep access tokens in memory.
2. Not setting an expiration.
Calling jwt.sign(payload, secret) with no expiresIn produces a token that never expires. Combined with no revocation strategy, a leaked token is valid forever. Always set expiresIn, and prefer minutes for access tokens.
3. Not whitelisting algorithms in verify.
Calling jwt.verify(token, secret) without an algorithms option means an attacker can craft a token with "alg": "none" and skip signature verification entirely. Always pass { algorithms: ['HS256'] } (or whichever algorithm you actually use).
4. No refresh token rotation. Issuing a refresh token at login and reusing the same one for 30 days means a single theft gives the attacker 30 days of access. Rotate the refresh token on every use, store it in the database, and detect reuse as a theft signal.
5. Putting sensitive data in the payload. The payload is base64url-encoded, not encrypted. Anyone who intercepts the token can read it. Never put passwords, full credit card numbers, or PII in JWT claims. Store the user ID only, and look up sensitive data server-side.
6. Using the same secret for access and refresh tokens. If you use one secret, a leak compromises both token types. Use separate secrets (or better, separate keys with RS256) so the blast radius of any leak is contained.
Interview Questions
1. "Walk me through what happens when a user logs in with JWT authentication."
The client sends credentials (email + password) to a /login endpoint over HTTPS. The server looks up the user, verifies the password hash with bcrypt, and if valid, issues two tokens. The access token is a short-lived JWT (10-15 minutes) containing the user ID and role, signed with the access secret. The refresh token is a long-lived JWT (7-30 days) containing minimal claims and a unique jti identifier, signed with a separate refresh secret. The refresh token's jti and a hash of the token are stored in the database so it can be revoked. The access token is returned in the JSON response body and held in memory by the client. The refresh token is set as an httpOnly, Secure, SameSite=Strict cookie scoped to the /auth/refresh path. On every subsequent API request, the client sends the access token in the Authorization: Bearer header, and the server verifies the signature and reads the claims without touching the database.
2. "Should I store JWTs in localStorage or in a cookie? Explain the tradeoffs."
Neither is universally correct — it depends on the threat model. localStorage is vulnerable to XSS: any JavaScript running on your origin, including third-party scripts and malicious dependencies, can read every token in it. There is no defense once an attacker has JS execution. Cookies with the httpOnly flag are inaccessible to JavaScript, so XSS cannot steal them — but they are sent automatically with cross-origin requests, which opens up CSRF. CSRF is mitigated with SameSite=Strict (or Lax) and CSRF tokens on state-changing endpoints. The modern best practice is a hybrid: store the access token in memory (a JavaScript variable that vanishes on reload) and store the refresh token in an httpOnly, Secure, SameSite=Strict cookie scoped to the refresh endpoint. On page reload, the client silently calls /refresh to get a new access token.
3. "How do you revoke a JWT before it expires?"
JWTs are stateless, so there is no built-in revocation. Three strategies exist. First, keep access tokens so short-lived (5-15 minutes) that revocation is rarely necessary — by the time you notice a token is compromised, it has effectively expired. Second, maintain a Redis blocklist of revoked jti values and check it on every verification; entries auto-expire when the token would have expired. Third, rotate refresh tokens on every use and store them in the database — if a stolen token is used, the legitimate user's next refresh fails, signaling theft, and you can revoke the entire token family. Most production systems combine all three: short access expiry plus refresh rotation, with a blocklist for high-value endpoints.
4. "What is the difference between HS256 and RS256, and when would you use each?"
HS256 is symmetric — the same secret signs and verifies. It is fast and simple, but every service that verifies tokens must also possess the signing secret, which means a compromised verifier can forge new tokens. RS256 is asymmetric — a private key signs and a public key verifies. The signing service is the only one with the private key; verifiers (microservices, partners, mobile apps) only need the public key, which can be distributed safely. Use HS256 for monoliths where one service signs and verifies its own tokens. Use RS256 when multiple services need to verify tokens from one trusted issuer, or when you publish a JWKS endpoint for third parties — OAuth providers, public APIs, and microservice architectures.
5. "Why is putting sensitive data like a password in a JWT payload a bad idea?"
Because the payload is base64url-encoded, not encrypted. Anyone who intercepts the token — by sniffing traffic, reading server logs, or pulling it from localStorage — can paste it into jwt.io and read every claim in plain text. The signature only guarantees that nobody has tampered with the payload; it does not hide the contents. Put only what verifiers need to make authorization decisions: the user ID, role, and maybe a tenant ID. Never store passwords, credit card numbers, social security numbers, API keys, or any PII. If a verifier needs additional user data, look it up server-side using the sub claim.
Quick Reference — JWT Cheat Sheet
+---------------------------------------------------------------+
| JWT CHEAT SHEET |
+---------------------------------------------------------------+
| |
| STRUCTURE: |
| header.payload.signature (base64url, dot-separated) |
| Payload is ENCODED, not encrypted -> never store secrets |
| |
| ALGORITHMS: |
| HS256 -> symmetric, one secret, monoliths |
| RS256 -> asymmetric, public/private, microservices |
| NEVER allow alg: none -> always whitelist algorithms |
| |
| STANDARD CLAIMS: |
| sub -> subject (user id) |
| iat -> issued at (auto-set by jsonwebtoken) |
| exp -> expiration (Unix seconds) |
| iss -> issuer |
| aud -> audience |
| jti -> unique token id (use for blocklist/rotation) |
| |
| SIGNING: |
| jwt.sign(payload, secret, { |
| algorithm: 'HS256', |
| expiresIn: '15m', |
| }) |
| |
| VERIFYING: |
| jwt.verify(token, secret, { |
| algorithms: ['HS256'], // ALWAYS whitelist |
| }) |
| |
+---------------------------------------------------------------+
+---------------------------------------------------------------+
| ACCESS + REFRESH PATTERN |
+---------------------------------------------------------------+
| |
| ACCESS TOKEN: |
| - 5-15 minute expiry |
| - Carries user claims (id, role) |
| - Sent in Authorization: Bearer header |
| - Stored in MEMORY (not localStorage) |
| |
| REFRESH TOKEN: |
| - 7-30 day expiry |
| - Minimal claims + unique jti |
| - Stored in httpOnly, Secure, SameSite=Strict cookie |
| - Scoped to /auth/refresh path |
| - Tracked in DB, rotated on every use |
| - Reuse detection -> revoke entire family |
| |
+---------------------------------------------------------------+
+---------------------------------------------------------------+
| STORAGE DECISION TABLE |
+---------------------------------------------------------------+
| |
| Threat | localStorage | httpOnly cookie |
| ------------------+--------------+---------------- |
| XSS | VULNERABLE | SAFE |
| CSRF | SAFE | Mitigate w/ SameSite |
| Auto-sent | NO | YES |
| Mobile/native API | EASY | AWKWARD |
| |
| WINNER for browsers: httpOnly cookie (refresh) |
| + memory (access) |
| |
+---------------------------------------------------------------+
| Concern | Wrong Way | Right Way |
|---|---|---|
| Storage | localStorage.setItem('jwt', token) | Memory + httpOnly cookie |
| Expiry | No expiresIn | expiresIn: '15m' for access |
| Verify | jwt.verify(token, secret) | jwt.verify(token, secret, { algorithms: ['HS256'] }) |
| Payload | { password, ssn, ... } | { sub, role } only |
| Revocation | "JWTs can't be revoked" | Short expiry + rotation + blocklist |
| Secrets | One shared secret | Separate access + refresh secrets |
Prev: Lesson 7.4 -- Caching with Redis Next: Lesson 8.2 -- Session-Based Authentication
This is Lesson 8.1 of the Node.js Interview Prep Course -- 10 chapters, 42 lessons.