Skip to content

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:

  1. JWT tokens - Self-contained, stateless, each service verifies independently
  2. 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:

AspectJWTSession-Based
StateStateless (token contains all data)Stateful (server stores session)
RevocationDifficult (need blacklist or short expiry)Instant (delete from store)
StorageClient-side onlyServer-side (Redis/DB)
Token SizeLarge (contains user data)Small (just session ID)
ScalingEasy (no shared state)Requires shared session store
SecurityData visible in payloadData 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:

gateway/src/auth/jwt-middleware.ts
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:

gateway/src/auth/token-blacklist.ts
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 bytes
Payload: ~1500 bytes (userId, email, roles, permissions, org memberships)
Signature: ~256 bytes
Total: ~1800 bytes base64 encoded

Every 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:

gateway/src/auth/session-store.ts
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:

gateway/src/middleware/forward-user.ts
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:

user-service/src/middleware/extract-user.ts
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:

gateway/src/auth/auth-routes.ts
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-login
req.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:

docker-compose.yml
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.conf

Gateway Dependency

Every request must go through the gateway. Direct service-to-service calls need internal authentication.

I handle this with internal API keys:

order-service/src/middleware/internal-auth.ts
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:

ScenarioRecommendationReason
Internal microservices with gatewaySession + RedisSimple, secure, instant revocation
Third-party API accessJWTStateless, standard, no session cookie
Mobile app + backendJWT (short-lived)No cookie dependency
Single-page app + backendEither worksSession simpler for revocation
Federated auth (SSO)JWTDesigned for cross-domain
High-security (banking, healthcare)Session + RedisMaximum control, instant lockout

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:

  1. Validates session against Redis
  2. Loads user context into request
  3. Forwards user context as headers to microservices
  4. Handles rate limiting and logging centrally

Microservices:

  1. Trust the gateway (internal network)
  2. Extract user context from headers
  3. 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 this
const payload = JSON.parse(atob(token.split('.')[1]));
console.log(payload.email); // Visible to anyone with the token

JWT 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:

gateway/src/auth/refresh-flow.ts
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:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments