Why I Studied This
When building my first API server, I was lost on how to implement login functionality. "I'll use sessions," I thought, storing sessions in server memory. But when I restarted the server, all users got logged out. I heard Redis could help, but session sharing gets complex when scaling to multiple servers.
"Use JWT," someone advised. "Token-based authentication," they called it. But I couldn't grasp how the server could maintain login state without storing anything. Tokens live on the client? Won't someone forge it? But there's a signature for security? I didn't even know what a signature was.
At first, I thought "JWT is an encrypted token," but then discovered Base64 decoding reveals everything. Shocked. If it's not even encrypted, how is it secure? Why use it? Questions piled up.
What Confused Me
1. How to Trust the Token?
If clients hold the token, can't users manipulate it? Change the payload to say "I'm admin" and fool the server? Sessions are server-side, so they're safe. But client-side tokens seem risky.
2. Is Base64 Encryption?
JWT looked like random characters with 3 parts separated by .. Initially thought "this is encrypted," but Base64 decoding shows everything plainly. Wait, this isn't encryption? Why use it this way?
3. What's a Signature?
"Signatures prevent forgery," they said. But what is a signature? Just a hash? What are HMAC and RSA? Why does having a secret key make it secure? Didn't understand.
4. Better Than Sessions?
Sessions let servers invalidate anytime since server stores state. JWT can't be invalidated until expiration once issued. Isn't that worse for security? Why even use JWT?
The Aha Moment: "Ticket with Stamp"
A colleague explained it like this:
"Sessions are like getting a number ticket at an amusement park. The entrance gives you '12' and notes 'Number 12 is John.' Every ride, you show '12,' and staff check their notes: 'Number 12 is John, okay to ride.' Problem: the park must manage everyone's list. Memory burden, and if there are multiple parks, sharing the list gets complicated.
JWT is like a ticket with 'Name: John, Age: 25, Entry: 10am' written on it, plus the park's stamp. Next visit, you show the ticket, staff check the stamp, 'Oh, John.' The park has no list. All info is on the ticket. That's Stateless."
This metaphor clicked. Sessions are server "memory," JWT carries info in the token itself so the server doesn't need to remember. What's the stamp? The signature. Without a valid stamp (signature), you can't get in. That's JWT's core security.
Understanding JWT Structure
First time seeing JWT, it looked like:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEyMzQ1LCJuYW1lIjoiQWxpY2UifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Split into 3 parts (by .):
- Header: Algorithm, token type
- Payload: Actual data (user info)
- Signature: Sign (prevent forgery)
1. Header - Which Algorithm
{
"alg": "HS256", // HMAC SHA-256
"typ": "JWT"
}
Base64 encoded: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. Here, HS256 means HMAC SHA-256 algorithm. Later learned this is symmetric key - only server needs the secret. There's also RSA (asymmetric), but that's another topic.
2. Payload - Where Data Lives
{
"userId": 12345,
"name": "Alice",
"exp": 1735689600, // Expiration (timestamp)
"iat": 1735603200 // Issued at
}
Base64 encoded: eyJ1c2VySWQiOjEyMzQ1LCJuYW1lIjoiQWxpY2UifQ. Critical: Base64 is encoding, not encryption. Anyone can decode and see contents. Never put passwords or SSNs here.
JWT has standard claims I learned later:
iss(issuer): Token issuersub(subject): Token subject (usually user ID)aud(audience): Token audienceexp(expiration): Expiration timeiat(issued at): Issue timenbf(not before): Token activation time
Didn't know these existed at first, but studying OAuth2 and OpenID Connect taught me these standard claims matter.
3. Signature - Core of Forgery Prevention
Most important part. Signature is made like:
HMACSHA256(
base64(header) + "." + base64(payload),
secret_key
)
Result: SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Signature uses a secret key only the server knows. If someone modifies the payload to say "I'm admin," the signature won't match and server rejects it during verification. Can't create valid signature without the secret.
Process in Code
1. Login (Issue Token)
const jwt = require('jsonwebtoken');
app.post('/login', (req, res) => {
const { email, password } = req.body;
const user = db.findUser(email, password);
if (!user) {
return res.status(401).send('Login failed');
}
const token = jwt.sign(
{
userId: user.id,
name: user.name,
role: user.role
},
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
res.json({ token });
});
Important: manage secret in environment variables. Hardcoding in code risks exposure when pushed to GitHub. Store in .env and add to .gitignore.
2. Authenticated Request
// Client
const token = localStorage.getItem('token');
fetch('/api/profile', {
headers: { 'Authorization': `Bearer ${token}` }
});
// Server
app.get('/api/profile', (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).send('No token');
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const user = db.getUserById(decoded.userId);
res.json(user);
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).send('Token expired');
}
res.status(401).send('Invalid token');
}
});
jwt.verify() verifies the signature. If token is tampered or expired, throws error. Server queries no storage here. Token has info, just verify signature. That's Stateless core.
Session vs JWT: The Real Difference
Session
1. Login success
2. Server: Generate session ID → store in memory/Redis/DB
sessions = {
"abc123": { userId: 12345, name: "Alice", ... }
}
3. Client: Store session ID in cookie
4. Next request: Send session ID via cookie
5. Server: Query storage with session ID for user info
Problems:
- Server stores all sessions → memory burden
- Server restart → sessions lost (Redis helps)
- Multiple servers → need session sharing (Sticky Session or Redis)
JWT
1. Login success
2. Server: Generate JWT (includes user info)
3. Client: Store JWT (localStorage or cookie)
4. Next request: Send JWT
5. Server: Only verify JWT (no storage query!)
Pros:
- Stateless: Server stores nothing
- Scalability: Multiple servers no problem (any server can verify)
- Microservices: Each service verifies independently
Cons:
- Hard to invalidate (logout doesn't stop token until expiration)
- Large size (session ID is bytes, JWT is 200+ bytes)
- Sensitive data exposure risk (Base64 isn't encryption)
JWT Is NOT Encryption - Most Important Lesson
Initially thought "JWT is encrypted token." Actually, it's just encoding.
const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEyMzQ1LCJyb2xlIjoiYWRtaW4ifQ.xxx";
// Base64 decode reveals contents!
const parts = token.split('.');
const payload = JSON.parse(atob(parts[1]));
console.log(payload); // { userId: 12345, role: "admin" }
JWT is encoding, not encryption! Anyone can see contents. Never put passwords, SSNs, credit card numbers.
"Then how is it secure?" → Signature.
How Signature Prevents Forgery
Forgery attempt:
1. Eve modifies payload: { userId: 99999, role: "admin" }
2. Base64 encodes to manipulate token
3. Sends to server
Server verification:
HMAC(header + payload, secret_key) != token signature
→ ❌ Verification fails! Forgery detected
Can't create valid signature without secret key. If Eve changes payload, signature won't match and server rejects. That's JWT's core security.
HMAC vs RSA: Learned Later
Initially only used HMAC (HS256), later learned RSA (RS256) exists.
- HMAC (HS256): Symmetric key. Only server has secret. Same key for issue and verify.
- RSA (RS256): Asymmetric key. Private key signs, public key verifies. Multiple services can verify with public key.
RSA is better for microservices. Auth server has private key to issue tokens, other services only need public key to verify. No need to share secret, better security.
JWT Problems: Hard to Invalidate
1. Token Invalidation Difficulty
Problem: User logs out, but token still valid
// Logout
localStorage.removeItem('token');
// But if Eve copied the token?
// Still usable until expiration!
Sessions can be immediately invalidated by deleting server-side. JWT can't be stopped once issued until expiration. JWT's biggest weakness.
Solutions:
- Short expiration (15min): Limit damage
- Refresh Token: Access Token short, Refresh Token long
- Blacklist: Store invalidated tokens in DB (loses Stateless advantage)
- Token Rotation: Issue new token each time
2. Large Size
Session ID: 6 bytes
JWT: 200+ bytes
Every request → bandwidth waste
Especially problematic on mobile. Sending 200 bytes in headers constantly. Minimize payload or use JWT only for sensitive requests.
3. Sensitive Data Exposure
Payload visible to anyone. Accidentally putting passwords or personal info is disastrous.
Refresh Token Pattern: Practical Solution
To solve JWT invalidation, use Refresh Token pattern. Most realistic approach.
// Access Token: 15min (short)
// Refresh Token: 7days (long, stored in DB)
// Login
app.post('/login', (req, res) => {
const user = authenticate(req.body);
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ userId: user.id, tokenType: 'refresh' },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' }
);
// Store Refresh Token in DB (for invalidation)
db.saveRefreshToken(user.id, refreshToken);
res.json({ accessToken, refreshToken });
});
// When Access Token expires
app.post('/refresh', (req, res) => {
const { refreshToken } = req.body;
// Check DB (validate if not invalidated)
if (!db.isValidRefreshToken(refreshToken)) {
return res.status(401).send('Please login again');
}
try {
const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
// Issue new Access Token
const newAccessToken = jwt.sign(
{ userId: decoded.userId },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken: newAccessToken });
} catch (err) {
res.status(401).send('Invalid refresh token');
}
});
// Logout
app.post('/logout', (req, res) => {
const { refreshToken } = req.body;
// Delete Refresh Token from DB (invalidate)
db.deleteRefreshToken(refreshToken);
res.send('Logout successful');
});
Pros:
- Short Access Token reduces theft risk
- Refresh Token stored in DB, can immediately invalidate
- Logout deletes Refresh Token → can't reissue
Cons:
- Refresh Token needs DB storage, not fully Stateless
- Increased complexity
No perfect solution. Accepted the trade-off.
JWT Storage: localStorage vs httpOnly Cookie
Where to store JWT was another dilemma.
1. localStorage
const { token } = await fetch('/login').then(r => r.json());
localStorage.setItem('token', token);
fetch('/api/profile', {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
Pros: Simple. JavaScript accessible. Cons: Vulnerable to XSS. Malicious scripts can steal token.
2. httpOnly Cookie
// Server
res.cookie('token', token, {
httpOnly: true, // JavaScript can't access
secure: true, // HTTPS only
sameSite: 'strict'
});
// Client automatically sends cookie (no header setup needed)
Pros: XSS protection. JavaScript can't access. Cons: CSRF vulnerable (need CSRF token defense).
Conclusion: httpOnly Cookie + CSRF defense is safer.
OAuth2 and JWT Connection
Studying OAuth2, JWT appeared. OAuth2 is authentication/authorization framework, often uses JWT as Access Token format.
1. User logs in with Google
2. Google issues Authorization Code
3. Server requests Access Token with Code
4. Google issues JWT format Access Token
5. Server verifies JWT → confirms user info
Access Tokens from Google, GitHub, Facebook are mostly JWT format. OpenID Connect uses JWT as standard.
Production Lessons: Common Mistakes
1. Hardcoded Secret
// ❌ Never do this
const token = jwt.sign(payload, 'my-secret-key');
// ✅ Use environment variables
const token = jwt.sign(payload, process.env.JWT_SECRET);
2. Too Long Expiration
// ❌ 7 days too long
jwt.sign(payload, secret, { expiresIn: '7d' });
// ✅ Short, use Refresh Token
jwt.sign(payload, secret, { expiresIn: '15m' });
3. Using Without HTTPS
Sending JWT over HTTP allows interception. Must use HTTPS.
4. Sensitive Info in Payload
// ❌ Don't put password
jwt.sign({ userId: 123, password: 'secret' }, secret);
// ✅ Minimal info only
jwt.sign({ userId: 123, role: 'user' }, secret);
5. Detailed Error Messages
// ❌ Gives hints to attackers
res.status(401).send('Signature mismatch');
// ✅ Generic message
res.status(401).send('Invalid token');
Summary
How I understand JWT's core:
- Stateless auth - Server stores no state. Token carries info.
- Signature prevents forgery - Can't manipulate without secret. HMAC or RSA.
- Not encryption - Base64 encoding. Anyone can see contents. No sensitive data.
- Good scalability - Multiple servers fine. Suits microservices.
- Hard to invalidate - Refresh Token pattern helps. No perfect solution.
- Storage matters - httpOnly Cookie better for XSS defense. Defend CSRF separately.
- Short expiration - Around 15min. Reduces theft risk.
- HTTPS required - HTTP allows interception.
JWT is "traded session's memory burden for scalability, accepted token management complexity." No perfect solution. Learned choosing between session and JWT depends on situation.
JWT is better for microservices or many servers. Sessions are better when security is critical or immediate invalidation needed. Both have pros/cons. No need to always use JWT - that's what I learned.