Sessions vs Tokens (JWT)
Stage 3 · Auth, Identity & Security · B.U.I.L.D. letter: L
You vibe-coded a login page, stored the user in
localStorage, and called it done. It worked in the demo. It will get someone's account stolen in production. Here's what actually needs to happen after the password check — and why the answer is not as simple as "just use JWTs."
⚠️ The vibe trap
JWTs look like encrypted blobs — random characters, clearly scrambled — so developers routinely store sensitive data inside them: user emails, roles, even payment tier flags. They're not encrypted. They're base64-encoded, which any eight-year-old can reverse in a browser console. The other classic mistake is dropping the token into localStorage because it's the easiest place. That hands it to any third-party script that loads on your page via an XSS attack — no password required.
🌊 HTTP is Stateless — So How Does "Logged In" Even Work?
HTTP doesn't remember you. Every request is a blank slate. After your server checks the password and says "yep, that's Bob," the very next request from Bob's browser arrives with zero memory of that conversation. Your app has to stitch identity back together on every single request.
Two fundamentally different strategies exist for doing this.
Mental model: Think of a hotel. Option A: the hotel keeps your profile on file and gives you a room number (session ID) — show the number at the front desk each time. Option B: the hotel prints all your booking details onto a laminated card with a tamper-evident seal (JWT) — any staff member can read the card without calling the front desk.
// WITHOUT any auth mechanism — every request is anonymous
app.get('/dashboard', (req, res) => {
// Who is this? No idea. HTTP told us nothing.
res.json({ user: null });
});
// WITH a session cookie — the browser sends the session ID automatically
app.get('/dashboard', requireAuth, (req, res) => {
// req.session.userId was populated when they logged in
res.json({ user: req.session.userId });
});
Why it matters: Every auth strategy you'll ever use — sessions, JWTs, OAuth tokens, magic links — is just a different answer to this same statelessness problem.
Common mistake: Assuming the browser "knows" who's logged in between page loads without any mechanism to prove it. It doesn't. You have to send proof with every request.
🗄️ Server-Side Sessions
When a user logs in, you create a session record on the server (in memory, Redis, a database — anywhere) and hand the browser a short, random session ID inside a cookie. On every subsequent request, the browser sends that cookie, you look up the session ID, and you find the user data on the server side.
Mental model: The session ID is like a coat-check ticket. It's meaningless by itself. The coat (the real user data) lives on the server. Steal the ticket and you can get the coat — which is why you protect it carefully — but the ticket itself contains nothing useful.
// npm install express-session
const session = require('express-session');
app.use(session({
secret: process.env.SESSION_SECRET, // long random string, never hardcode
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // JS cannot read this cookie — blocks XSS theft
secure: true, // only sent over HTTPS
sameSite: 'lax', // blocks most CSRF attacks
maxAge: 1000 * 60 * 60 * 24 // 24 hours
}
}));
// After verifying password:
app.post('/login', async (req, res) => {
const user = await db.users.findByEmail(req.body.email);
const valid = await bcrypt.compare(req.body.password, user.passwordHash);
if (!valid) return res.status(401).json({ error: 'Bad credentials' });
req.session.userId = user.id; // stored server-side; only the ID goes in the cookie
res.json({ ok: true });
});
Why it matters: Revocation is trivial — delete the session record and the user is logged out instantly, everywhere. This is why banking apps use sessions: if someone reports fraud, you can kill every active session in one database delete.
Common mistake: Omitting httpOnly: true. Without it, document.cookie in any JavaScript (including injected scripts) can read the session ID and send it to an attacker's server.
🔐 JWTs — What They Actually Are
A JSON Web Token is three base64url-encoded chunks joined by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 ← Header (algorithm + type)
.eyJ1c2VySWQiOiIxMjMiLCJleHAiOjE3NTAwMDAwMDB9 ← Payload (claims)
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c ← Signature
The server signs the payload with a secret key. Anyone can decode the header and payload (try jwt.io right now — paste any JWT). The signature only proves the payload hasn't been tampered with. Nothing is hidden.
// npm install jsonwebtoken
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET; // never hardcode
// SIGN — after login
function createTokens(userId) {
const accessToken = jwt.sign(
{ userId, role: 'student' }, // payload — visible to anyone who has the token
SECRET,
{ expiresIn: '15m' } // short-lived; limits damage if stolen
);
const refreshToken = jwt.sign(
{ userId },
SECRET,
{ expiresIn: '7d' }
);
return { accessToken, refreshToken };
}
// VERIFY — on every protected request
function requireJwt(req, res, next) {
const auth = req.headers.authorization; // "Bearer <token>"
if (!auth) return res.status(401).json({ error: 'No token' });
try {
const payload = jwt.verify(auth.split(' ')[1], SECRET);
req.user = payload;
next();
} catch (err) {
// catches expired tokens, tampered signatures, wrong secret — all of it
res.status(401).json({ error: 'Invalid or expired token' });
}
}
Mental model: A JWT is a signed government ID. Everyone can read what's on it. The hologram (signature) proves it wasn't forged. But if someone photocopies your ID before it expires, they can use it — you can't call the ID back.
Why it matters: JWTs are stateless — the server doesn't need a database lookup on every request. This scales beautifully across multiple servers. The cost is revocation: a valid JWT works until it expires, even if the user logs out or gets banned.
Common mistake: Putting sensitive data in the payload — email, address, credit card, internal pricing flags. Decode the payload in two seconds at jwt.io. Store only the minimum needed: userId, role, exp.
🔄 Refresh Tokens and the Expiry Dance
A 15-minute access token means users get logged out constantly if you don't handle refresh. A refresh token is a second, longer-lived JWT (or opaque token) stored in an HttpOnly cookie. When the access token expires, the client silently swaps the refresh token for a new access token — the user never notices.
// Client-side fetch wrapper (conceptual — framework agnostic)
async function apiFetch(url, options = {}) {
let res = await fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${getAccessToken()}` // from memory, NOT localStorage
}
});
if (res.status === 401) {
// Try to refresh
const refresh = await fetch('/auth/refresh', {
method: 'POST',
credentials: 'include' // sends HttpOnly refresh-token cookie automatically
});
if (refresh.ok) {
const { accessToken } = await refresh.json();
storeAccessToken(accessToken); // store in memory / React state, not localStorage
// Retry original request once
res = await fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${accessToken}`
}
});
} else {
redirectToLogin();
}
}
return res;
}
Why it matters: Short access token lifetimes limit the blast radius of token theft. The refresh token, safely in an HttpOnly cookie, is inaccessible to JavaScript entirely.
Common mistake: Storing the access token in localStorage. Any XSS payload — even from a compromised npm package — can do localStorage.getItem('token') and exfiltrate it. Store the access token in memory (a module-level variable, React state, Zustand store) and the refresh token in an HttpOnly cookie.
🏷️ Secure Cookie Flags — The Full Breakdown
| Flag | What it does | Without it |
|---|---|---|
HttpOnly | JS cannot read the cookie at all | XSS can steal it with document.cookie |
Secure | Cookie only sent over HTTPS | Exposed on any HTTP connection (coffee shop wifi) |
SameSite=Lax | Not sent on cross-site POST requests | Classic CSRF vector |
SameSite=Strict | Not sent on ANY cross-site request | Breaks OAuth redirects; usually too strict |
Max-Age / Expires | Cookie dies at a defined time | Lives until the browser closes (or forever on "remember me") |
// Express — manually setting a cookie the right way
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days in ms
path: '/auth' // only sent to /auth/* routes — limits exposure further
});
Mental model: HttpOnly is the lock that keeps client-side scripts out. Secure is the envelope that keeps the network from peeking. SameSite is the rule that stops other websites from using your cookie on your behalf.
Common mistake: Setting cookies in development without Secure and forgetting to add secure: process.env.NODE_ENV === 'production'. The cookie then silently sends over HTTP in production too, because you never toggled the flag.
📊 Sessions vs Tokens — When to Use Which
| Concern | Server-Side Session | JWT |
|---|---|---|
| Instant revocation | Yes — delete the session | Hard — wait for expiry |
| Scales across many servers | Needs shared session store (Redis) | Yes — stateless by design |
| Database hit per request | Yes | No |
| User data visible in token | No (ID only in cookie) | Yes (payload is readable) |
| Best for | Traditional web apps, banking | APIs, mobile apps, microservices |
Neither is universally better. A hybrid — sessions for your web app, short-lived JWTs for your API — is a completely legitimate architecture.
🛠️ Your Mission
Open the app you vibe-coded and run this investigation:
-
Find where your token or session lives. Open DevTools → Application → Cookies and Local Storage. Is your token in
localStorage? That needs to move to memory (access token) and anHttpOnlycookie (refresh token). -
Audit your cookie flags. In DevTools → Application → Cookies, check each auth cookie. Is
HttpOnlychecked? IsSecurechecked? IsSameSiteset toLaxorStrict? Fix any that are missing. -
Decode your JWT payload. Copy any JWT your app issues and paste it into jwt.io. Read what's in the payload. Remove any field that an attacker could misuse — internal roles, pricing flags, PII.
-
Check your expiry. If your access token has
expset to 30 days from now, set it to 15 minutes and implement a refresh flow.
Fix one concrete weakness you found. One is enough for today — ship the fix, note what you changed.
✅ You're Done When…
- You have completed the Security Audit Checklist items relevant to broken authentication (A07:2021) for your app
- Your auth cookies all have
HttpOnly,Secure, andSameSiteflags set correctly in your code - Your JWT payload contains no sensitive data — only
userId,role(if needed), andexp - Your access tokens expire in 15 minutes or less, and you have (or have planned) a refresh token flow
- You can explain to someone else the difference between why sessions make revocation easy and why JWTs make scaling easy
➡️ Next: OAuth & Social Login.
Build It Right, Or Don't Build It At All. 🏛️