JWT vs Session-Based Authentication for Microservices
I was designing authentication for a microservices architecture and made the classic mistake: I picked JWTs because everyone said they were “stateless” and “perfect for microservices.” Six months later, I was debugging token revocation issues, dealing with bloated tokens, and realizing I had created more problems than I solved.
Let me walk through what I learned about JWT vs session-based authentication for microservices, and when each approach makes sense.
The Problem
When you split a monolith into microservices, authentication becomes complex. Each service needs to verify the user’s identity. The question is: how do you share authentication state across services?
I had two options:
- JWT tokens - Self-contained, stateless, each service verifies independently
- Session-based - Server-side storage, shared state, requires coordination
The common advice is “use JWTs for microservices because they’re stateless.” This advice is often wrong.
Comparison: JWT vs Session-Based
Here’s the key comparison I wish I had seen earlier:
| Aspect | JWT | Session-Based |
|---|---|---|
| State | Stateless (token contains all data) | Stateful (server stores session) |
| Revocation | Difficult (need blacklist or short expiry) | Instant (delete from store) |
| Storage | Client-side only | Server-side (Redis/DB) |
| Token Size | Large (contains user data) | Small (just session ID) |
| Scaling | Easy (no shared state) | Requires shared session store |
| Security | Data visible in payload | Data hidden server-side |
JWT: When It Works
JWTs shine when you need:
- Truly stateless verification (each service can verify independently)
- Federated authentication across domains
- Short-lived tokens for API access
- OAuth/OpenID Connect flows
Here’s a typical JWT implementation at an API gateway:
import { verify } from 'jsonwebtoken';
interface JwtPayload { userId: string; email: string; roles: string[];}
export function jwtMiddleware(publicKey: string) { return async (req: Request, res: Response, next: NextFunction) => { const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) { return res.status(401).json({ error: 'No token provided' }); }
const token = authHeader.slice(7);
try { const payload = verify(token, publicKey, { algorithms: ['RS256'] }) as JwtPayload;
req.user = payload; next(); } catch (err) { return res.status(401).json({ error: 'Invalid token' }); } };}This looks clean. Each microservice can verify the token using the public key. No database calls needed.
The JWT Problems I Encountered
Problem 1: Token Revocation
A user’s credentials were compromised. I needed to invalidate their tokens immediately. But JWTs are self-contained - I can’t “delete” them.
I had to implement a token blacklist:
import { Redis } from 'ioredis';
export class TokenBlacklist { private redis: Redis;
constructor(redisUrl: string) { this.redis = new Redis(redisUrl); }
async revokeToken(tokenId: string, expiresAt: number): Promise<void> { const ttl = Math.max(0, expiresAt - Date.now()); await this.redis.setex(`revoked:${tokenId}`, Math.floor(ttl / 1000), '1'); }
async isRevoked(tokenId: string): Promise<boolean> { const result = await this.redis.get(`revoked:${tokenId}`); return result !== null; }}Wait - now I have state. I’m hitting Redis on every request anyway. What happened to “stateless”?
As one Redditor pointed out:
“JWTs is rarely a good option, as they come with all sorts of issues that storing opaque hashed random values in a database doesn’t. It’s one of those things that like document dbs have become common, but 99% of the time is the wrong approach. I would keep the checks at the API gateway level.” — Ran4 (r/node)
Problem 2: Token Bloat
I added user permissions to the JWT so downstream services wouldn’t need to query the database. The token grew to 2KB:
Header: ~50 bytesPayload: ~1500 bytes (userId, email, roles, permissions, org memberships)Signature: ~256 bytesTotal: ~1800 bytes base64 encodedEvery API request included this 2KB token. For a page making 20 API calls, that’s 40KB of redundant data transfer.
Problem 3: Token Rotation
When I needed to add a new claim (organization role), existing tokens didn’t have it. Users had to log out and log back in to get the updated token.
For a system with 50,000 active users, this was a coordination nightmare.
Session-Based: What Actually Worked
I switched to sessions stored in Redis. The API gateway handles authentication and forwards user context to microservices.
Here’s the session implementation:
import session from 'express-session';import connectRedis from 'connect-redis';import { Redis } from 'ioredis';
const RedisStore = connectRedis(session);
export function createSessionStore(redisClient: Redis) { return new RedisStore({ client: redisClient, prefix: 'sess:', ttl: 86400, // 24 hours });}
export const sessionConfig = { store: createSessionStore(new Redis(process.env.REDIS_URL)), secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false, cookie: { secure: true, httpOnly: true, sameSite: 'strict' as const, maxAge: 86400000, // 24 hours },};The gateway authenticates and forwards user context:
export function forwardUserContext(req: Request, res: Response, next: NextFunction) { if (req.session?.userId) { // Add user context as headers for downstream services req.headers['x-user-id'] = req.session.userId; req.headers['x-user-email'] = req.session.email; req.headers['x-user-roles'] = JSON.stringify(req.session.roles); } next();}Microservices trust the gateway and extract user context:
export function extractUserContext(req: Request, res: Response, next: NextFunction) { const userId = req.headers['x-user-id'] as string; const email = req.headers['x-user-email'] as string; const roles = JSON.parse(req.headers['x-user-roles'] as string || '[]');
req.user = { userId, email, roles }; next();}Why Sessions Won for My Use Case
Instant Revocation
When a user logs out or their account is compromised:
router.post('/logout', async (req, res) => { req.session.destroy((err) => { if (err) { return res.status(500).json({ error: 'Logout failed' }); } res.clearCookie('sessionId'); res.json({ message: 'Logged out successfully' }); });});
router.post('/revoke-all-sessions', async (req, res) => { // Admin endpoint to revoke all sessions for a user const { userId } = req.body;
const pattern = `sess:*:${userId}`; const keys = await redis.keys(pattern);
if (keys.length > 0) { await redis.del(...keys); }
res.json({ message: `Revoked ${keys.length} sessions` });});Done. No blacklist, no waiting for token expiration.
Small Token Size
Session cookie: ~50 bytes JWT token: ~1800 bytes
For high-traffic APIs, this bandwidth savings is significant.
Centralized Control
I can change session data without reissuing tokens:
// Add organization to session without user re-loginreq.session.organizationId = newOrgId;await req.session.save();All downstream services see the updated context immediately.
The Tradeoffs of Session-Based
Sessions aren’t perfect. I traded some complexity for security and control.
Dependency on Redis
If Redis goes down, users can’t authenticate. I handle this with Redis clustering:
services: redis-master: image: redis:7-alpine command: redis-server --appendonly yes
redis-replica: image: redis:7-alpine command: redis-server --slaveof redis-master 6379 --appendonly yes depends_on: - redis-master
redis-sentinel-1: image: redis:7-alpine command: redis-sentinel /etc/redis/sentinel.conf volumes: - ./sentinel.conf:/etc/redis/sentinel.confGateway Dependency
Every request must go through the gateway. Direct service-to-service calls need internal authentication.
I handle this with internal API keys:
export function internalAuth(req: Request, res: Response, next: NextFunction) { const apiKey = req.headers['x-internal-api-key'];
if (apiKey !== process.env.INTERNAL_API_KEY) { return res.status(403).json({ error: 'Forbidden' }); }
next();}Decision Matrix
Here’s when I would choose each approach:
| Scenario | Recommendation | Reason |
|---|---|---|
| Internal microservices with gateway | Session + Redis | Simple, secure, instant revocation |
| Third-party API access | JWT | Stateless, standard, no session cookie |
| Mobile app + backend | JWT (short-lived) | No cookie dependency |
| Single-page app + backend | Either works | Session simpler for revocation |
| Federated auth (SSO) | JWT | Designed for cross-domain |
| High-security (banking, healthcare) | Session + Redis | Maximum control, instant lockout |
Recommended Pattern for API Gateway
The pattern that worked best for me:
text┌─────────────┐│ Client │└──────┬──────┘ │ Cookie: sessionId ▼┌─────────────────────────────────────────┐│ API Gateway ││ ┌─────────────┐ ┌─────────────────┐ ││ │ Session │ │ Rate Limiting │ ││ │ Auth │ │ Logging │ ││ └─────────────┘ └─────────────────┘ ││ ││ Forward: x-user-id, x-user-email ││ x-user-roles, x-org-id │└──────────────────┬──────────────────────┘ │ ┌───────────┼───────────┐ ▼ ▼ ▼┌──────────┐ ┌──────────┐ ┌──────────┐│ Service │ │ Service │ │ Service ││ A │ │ B │ │ C │└──────────┘ └──────────┘ └──────────┘ │ ▼ ┌─────────────┐ │ Redis │ │ Session │ │ Store │ └─────────────┘The gateway:
- Validates session against Redis
- Loads user context into request
- Forwards user context as headers to microservices
- Handles rate limiting and logging centrally
Microservices:
- Trust the gateway (internal network)
- Extract user context from headers
- No direct Redis access needed
Common Mistakes I Made
Mistake 1: Putting Sensitive Data in JWTs
I stored email addresses in JWTs without realizing the payload is readable:
// Anyone can decode thisconst payload = JSON.parse(atob(token.split('.')[1]));console.log(payload.email); // Visible to anyone with the tokenJWT payloads are encoded, not encrypted. Never store sensitive data.
Mistake 2: Long JWT Expiration
I set JWT expiration to 7 days for convenience. When I needed to revoke access, I had to wait 7 days for old tokens to expire naturally.
Now I use:
- Access tokens: 15 minutes
- Refresh tokens: 7 days
- Session-based: Revocable instantly
Mistake 3: Not Using Refresh Tokens Properly
I thought refresh tokens were optional. They’re essential for security:
router.post('/refresh', async (req, res) => { const refreshToken = req.cookies.refreshToken;
if (!refreshToken) { return res.status(401).json({ error: 'No refresh token' }); }
const storedToken = await redis.get(`refresh:${refreshToken}`);
if (!storedToken) { return res.status(401).json({ error: 'Invalid refresh token' }); }
const user = JSON.parse(storedToken);
// Rotate refresh token (security best practice) await redis.del(`refresh:${refreshToken}`); const newRefreshToken = generateSecureToken(); await redis.setex(`refresh:${newRefreshToken}`, 604800, JSON.stringify(user));
const newAccessToken = generateAccessToken(user);
res.cookie('refreshToken', newRefreshToken, { httpOnly: true, secure: true, sameSite: 'strict', maxAge: 604800000, });
res.json({ accessToken: newAccessToken });});Summary
In this post, I compared JWT and session-based authentication for microservices. The key point is that sessions with a Redis store are often simpler and more secure for API gateway patterns. JWTs have their place for third-party APIs and federated authentication, but the “stateless” advantage disappears when you need revocation. Choose based on your security requirements, not on what’s trendy.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 OWASP Session Management Cheat Sheet
- 👨💻 RFC 8725: JWT Best Practices
- 👨💻 Redis Session Store with connect-redis
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments