Your app just hit production. 10,000 users are logged in. Your server stores session data for each one in memory. Traffic doubles. Then triples. The server runs out of memory. Users get logged out randomly. You add more servers, but now sessions don’t sync across them.
This was the authentication nightmare of 2010. Facebook, Twitter, Google - they all faced the same problem: how do you authenticate millions of users without storing state on your servers?
The answer: a simple token with three parts separated by dots. A token that carries its own verification. A token that lets servers stay stateless.
They called it JWT - JSON Web Token.
Here’s what makes it interesting: JWT doesn’t store anything on the server. The client carries the proof. The server just verifies it. Like showing your passport at airport security - they don’t call your home country, they just check if the passport itself is authentic.
The Problem: Why Sessions Don’t Scale
Traditional sessions work like a parking valet service. You give them your car, they give you a ticket with a number, and they park your car in spot #47. When you return, you show the ticket, and they fetch your car from storage.
This works for one parking location. But what if you have 100 parking garages across the city and want to retrieve your car from any location? Now you need a central tracking system, network calls to find where the car is stored, and if that central system goes down, nobody can get their cars.
sequenceDiagram
participant U as User
participant S1 as Server 1
participant Redis as Session Store
participant S2 as Server 2
U->>S1: Login with credentials
S1->>Redis: Store session
Redis->>S1: Return session_id
S1->>U: Set cookie: session_id
Note over U,S2: Next request hits different server
U->>S2: Request with cookie
S2->>Redis: Fetch session
Redis->>S2: Return user data
S2->>U: Response
Note over Redis: If Redis crashes?<br/>Everyone logs out!
Problems with sessions:
- Memory overhead for every logged-in user
- Need shared session storage (Redis, database)
- Single point of failure
- Doesn’t work well across domains or in mobile apps
The Insight: Authentication That Travels with the Request
Instead of the server storing “who you are”, what if you carried proof of identity?
- Sessions (hotel key cards): Hotel knows you’re in room 403 because they have records
- JWT (passports): You prove who you are by showing a verified document
With JWT, the server doesn’t remember you. You show up with a token that proves you’re authenticated, and the server verifies it.
The key innovation: the proof can be verified without checking a database.
JWT Structure: Three Parts
A JWT looks like this:
1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjM0NTY3ODkwLCJuYW1lIjoiSm9obiBEb2UiLCJpYXQiOjE1MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Three parts: Header.Payload.Signature
Part 1: Header
1
2
3
4
{
"alg": "HS256",
"typ": "JWT"
}
Specifies the signing algorithm and token type. Base64URL encoded.
Part 2: Payload (Claims)
1
2
3
4
5
6
7
{
"user_id": 1234567890,
"email": "john@example.com",
"role": "admin",
"iat": 1516239022,
"exp": 1516242622
}
Contains user data and metadata. Also Base64URL encoded.
Critical: The payload is NOT encrypted. Anyone can decode it. Never put passwords or sensitive data here.
Part 3: Signature
1
2
3
4
signature = HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret_key
)
The signature proves:
- Token was created by someone with the secret key
- Data hasn’t been tampered with
Change even one character in the payload? Signature verification fails.
graph TB
subgraph "Creating JWT"
H[Header] --> E1[Encode]
P[Payload] --> E2[Encode]
E1 --> Concat[Combine]
E2 --> Concat
Concat --> Sign[Sign with Secret]
Secret[Secret Key] --> Sign
Sign --> Sig[Signature]
end
E1 --> Final[header.payload.signature]
E2 --> Final
Sig --> Final
style Secret fill:#ff6b6b
style Sig fill:#4caf50
How JWT Authentication Works
Step 1: User Logs In
sequenceDiagram
participant User
participant Server
participant DB
User->>Server: POST /login {email, password}
Server->>DB: Verify credentials
DB->>Server: Valid user
Server->>Server: Generate JWT
Server->>User: Return JWT
Note over User: Store JWT
Code example:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const jwt = require('jsonwebtoken');
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await db.findUser(email);
if (!user || !(await bcrypt.compare(password, user.password))) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign(
{ user_id: user.id, email: user.email, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '24h' }
);
res.json({ token });
});
Step 2: Accessing Protected Resources
sequenceDiagram
participant User
participant Server
User->>Server: GET /api/profile<br/>Authorization: Bearer JWT
Server->>Server: Verify signature
Server->>Server: Check expiration
alt Valid & Not Expired
Server->>User: Return data
else Invalid/Expired
Server->>User: 401 Unauthorized
end
Code example:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const authenticateJWT = (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
return res.status(403).json({ error: 'Invalid token' });
}
};
app.get('/api/profile', authenticateJWT, (req, res) => {
res.json({ user_id: req.user.user_id, email: req.user.email });
});
Symmetric vs Asymmetric Signing
HS256 (Symmetric): Same secret for signing and verifying
- Simpler to use
- All servers need the same secret
- If compromised, attacker can create tokens
RS256 (Asymmetric): Private key signs, public key verifies
- One auth server has private key
- All services verify with public key
- Even if a service is compromised, can’t create tokens
- Best for microservices
graph TB
subgraph "Asymmetric (RS256)"
Auth[Auth Service<br/>Private Key] -->|Signs| Token[JWT]
Token --> Service1[Service 1<br/>Public Key]
Token --> Service2[Service 2<br/>Public Key]
Token --> Service3[Service 3<br/>Public Key]
end
style Auth fill:#4caf50
JWT Security: Common Vulnerabilities
1. None Algorithm Attack
Attack: Attacker changes algorithm to “none” and removes signature.
1
{"alg": "none", "typ": "JWT"}
Protection:
1
jwt.verify(token, secret, { algorithms: ['HS256'] }); // Specify allowed algorithms
2. Algorithm Confusion
Attack: Server uses RS256, attacker changes to HS256 and signs with public key.
Protection:
1
jwt.verify(token, publicKey, { algorithms: ['RS256'] }); // Enforce algorithm
3. Weak Secrets
Bad:
1
const secret = 'secret'; // Brute-forceable
Good:
1
const secret = crypto.randomBytes(64).toString('hex');
4. Sensitive Data in Payload
JWT payload is readable by anyone!
Bad:
1
jwt.sign({ user_id: 123, credit_card: '4532-...' }, secret); // Visible!
Good:
1
jwt.sign({ user_id: 123, email: 'user@example.com' }, secret); // Only IDs
5. No Expiration
Bad:
1
jwt.sign({ user_id: 123 }, secret); // Valid forever
Good:
1
jwt.sign({ user_id: 123 }, secret, { expiresIn: '1h' });
6. Can’t Revoke Tokens
When user logs out, JWT is still valid until expiration.
Solutions:
- Token blacklist: Store revoked tokens in Redis
- Short-lived tokens: 15-min access token + refresh token
- Token versioning: Increment version on password change
Best Practices
1. Storage Location
Options:
localStorage
: Easy but vulnerable to XSS- HttpOnly cookies: Can’t be accessed by JavaScript (safer)
1
2
3
4
5
6
res.cookie('token', jwt, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 3600000
});
2. Short Expiration
1
2
const accessToken = jwt.sign(payload, secret, { expiresIn: '15m' });
const refreshToken = jwt.sign(payload, secret, { expiresIn: '7d' });
3. Always Use HTTPS
JWT in HTTP = anyone on network can intercept it.
4. Validate Everything
1
2
3
4
5
jwt.verify(token, secret, {
algorithms: ['HS256'],
issuer: 'myapp.com',
audience: 'myapp-api'
});
When NOT to Use JWT
- Need instant revocation - Banking, healthcare where immediate logout is critical
- Large data storage - JWT sent with every request, increases bandwidth
- Traditional server-rendered apps - Sessions are simpler
- Payload must be secret - JWT payload is readable
JWT vs Sessions: Quick Comparison
Feature | JWT | Sessions |
---|---|---|
State | Stateless | Server stores state |
Scaling | Easy horizontal scaling | Needs shared storage |
Revocation | Difficult | Easy |
Size | Larger (sent with each request) | Small (just session ID) |
Use case | APIs, microservices, mobile | Server-rendered apps |
Complete Example: Login and Protected Route
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
// Login
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await db.findUserByEmail(email);
if (!user || !(await bcrypt.compare(password, user.password))) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Access token (short-lived)
const accessToken = jwt.sign(
{ sub: user.id, email: user.email, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
// Refresh token (long-lived, stored in DB)
const refreshToken = jwt.sign(
{ sub: user.id },
process.env.REFRESH_SECRET,
{ expiresIn: '7d' }
);
await db.saveRefreshToken(user.id, refreshToken);
res.json({ accessToken, refreshToken });
});
// Protected route
app.get('/api/profile', authenticateJWT, async (req, res) => {
const user = await db.findUserById(req.user.sub);
res.json({ id: user.id, email: user.email, role: user.role });
});
// Refresh token
app.post('/refresh', async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token required' });
}
try {
const decoded = jwt.verify(refreshToken, process.env.REFRESH_SECRET);
const valid = await db.checkRefreshToken(decoded.sub, refreshToken);
if (!valid) {
return res.status(403).json({ error: 'Invalid refresh token' });
}
const user = await db.findUserById(decoded.sub);
const accessToken = jwt.sign(
{ sub: user.id, email: user.email, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken });
} catch (err) {
return res.status(403).json({ error: 'Invalid refresh token' });
}
});
// Logout
app.post('/logout', authenticateJWT, async (req, res) => {
await db.deleteRefreshToken(req.user.sub);
res.json({ message: 'Logged out' });
});
JWT in Microservices
JWT shines in distributed architectures:
sequenceDiagram
participant Client as Client App
participant Auth as Auth Service
participant User as User Service
participant Order as Order Service
participant Payment as Payment Service
Client->>Auth: 1. Login (email/password)
Auth->>Client: 2. Return JWT
Note over Client: Store JWT
Client->>User: 3. API Request + JWT
User->>User: Verify JWT locally
User->>Client: Response
Client->>Order: 4. API Request + JWT
Order->>Order: Verify JWT locally
Order->>Client: Response
Client->>Payment: 5. API Request + JWT
Payment->>Payment: Verify JWT locally
Payment->>Client: Response
Note over User,Payment: Each service verifies<br/>JWT independently
Why it works:
- One auth service issues tokens
- All services verify independently (no inter-service auth calls)
- Client includes JWT in every request
- Scales horizontally without coordination
Getting Started
1. Install:
1
npm install jsonwebtoken bcryptjs
2. Generate secret:
1
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
3. Create your first JWT:
1
2
3
4
5
6
7
8
9
10
11
12
const jwt = require('jsonwebtoken');
const token = jwt.sign(
{ user_id: 123, email: 'test@example.com' },
'your-secret-key',
{ expiresIn: '1h' }
);
console.log('JWT:', token);
const decoded = jwt.verify(token, 'your-secret-key');
console.log('Decoded:', decoded);
4. Test:
1
2
3
4
5
6
7
8
# Login
curl -X POST http://localhost:3000/login \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com", "password": "pass123"}'
# Use token
curl http://localhost:3000/api/profile \
-H "Authorization: Bearer <token>"
Debugging JWT
Use jwt.io to decode and inspect tokens visually.
Or in code:
1
2
3
const decoded = jwt.decode(token, { complete: true });
console.log('Header:', decoded.header);
console.log('Payload:', decoded.payload);
Common errors:
jwt expired
: Token’s exp claim has passed, implement refreshinvalid signature
: Secret key mismatch or tamperingjwt malformed
: Wrong format, check token extractioninvalid algorithm
: Algorithm mismatch, possible attack
Conclusion
JWT solved a fundamental problem: how to authenticate millions of users without server-side state.
What started as RFC 7519 became the foundation of modern API authentication. Companies like Netflix, Spotify, and Uber use JWT to authenticate billions of requests daily.
For more on distributed systems, check out our posts on Kafka for event streaming, Paxos consensus algorithm, and Distributed counter architecture.