Definition
- Stateless authentication – The client carries a self-contained signed JWT on every request; no server stores session state.
- Local validation – Each service checks the token's signature, expiry, issuer, and audience without calling the identity provider.
- Token IS the session – The identity and roles that a traditional session stored on the server now travel inside the signed token.
- Horizontal scalability – Any instance of any service handles any request because nothing is in instance memory.
- Revocation tradeoff – A self-contained token is valid until expiry; mitigate with short-lived access tokens and refresh rotation.
Imagine Rajesh visits several departments at National Bank of India during one trip. In the old system, the receptionist keeps a note saying "Rajesh is inside", and every clerk phones the receptionist to confirm, which breaks when there are many branches. Instead the bank gives Rajesh a tamper-proof pass when he proves his identity, and he shows it at every department. Each department reads and verifies the pass itself, with no phone call to anyone.
Understanding the Concepts
Why do traditional sessions break in microservices?
A classic monolith stores a session in server memory. In microservices with many instances behind a load balancer, a session in one instance is invisible to the others.
package com.bank.session;
public class SessionProblem {
public static void main(String[] args) {
System.out.println("Traditional session problem in microservices:");
System.out.println(" Instance-1 creates session for Rajesh (sessionId=ABC)");
System.out.println(" Next request hits Instance-2 via load balancer");
System.out.println(" Instance-2 has NO session ABC -> 401 Unauthorized");
System.out.println(" Rajesh appears logged out on every other request!");
System.out.println();
System.out.println(" Fix 1: Sticky sessions (defeats load balancing purpose)");
System.out.println(" Fix 2: Shared session store in Redis (adds dependency)");
System.out.println(" Fix 3: Stateless JWT (no server-side session at all) <- BEST");
}
}
Expected output:
Traditional session problem in microservices:
Instance-1 creates session for Rajesh (sessionId=ABC)
Next request hits Instance-2 via load balancer
Instance-2 has NO session ABC -> 401 Unauthorized
Rajesh appears logged out on every other request!
How does stateless JWT authentication work?
On login, the identity provider issues a signed JWT. The client stores it and sends it on every request. Each service validates locally.
package com.bank.session;
public class StatelessJwtFlow {
public static void main(String[] args) {
System.out.println("Stateless JWT flow:");
System.out.println(" 1. POST /auth/login {user: rajesh, password: ****}");
System.out.println(" 2. Auth service verifies credentials");
System.out.println(" 3. Auth service mints JWT: {sub: rajesh, roles: [CUSTOMER], exp: 5min}");
System.out.println(" 4. Client stores JWT");
System.out.println(" 5. GET /accounts/SAV2001 Authorization: Bearer <jwt>");
System.out.println(" 6. Account service validates JWT locally (signature + expiry)");
System.out.println(" 7. POST /transfers Authorization: Bearer <jwt>");
System.out.println(" 8. Transfer service validates same JWT locally");
System.out.println();
System.out.println("No server-side session. No shared state. Any instance handles any request.");
}
}
Expected output:
Stateless JWT flow:
1. POST /auth/login {user: rajesh, password: ****}
2. Auth service verifies credentials
3. Auth service mints JWT: {sub: rajesh, roles: [CUSTOMER], exp: 5min}
4. Client stores JWT
5. GET /accounts/SAV2001 Authorization: Bearer <jwt>
6. Account service validates JWT locally (signature + expiry)
7. POST /transfers Authorization: Bearer <jwt>
8. Transfer service validates same JWT locally
No server-side session. No shared state. Any instance handles any request.
How does each service validate the token?
The service checks the signature using the public key, confirms expiry, and verifies issuer and audience.
package com.bank.session;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class ServiceSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwkSetUri("https://auth.nbi.com/.well-known/jwks.json")
))
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated());
return http.build();
}
}
Validation checks per request:
1. Fetch public key from JWKS endpoint (cached)
2. Verify signature: HMAC or RSA
3. Check exp: not expired
4. Check iss: matches expected issuer
5. Check aud: matches this service
6. Extract sub and roles -> set SecurityContext
Why does this scale?
Because everything needed is in the token. Any instance of any service validates locally. Add or remove instances freely.
package com.bank.session;
public class WhyItScales {
public static void main(String[] args) {
System.out.println("Scalability with JWT:");
System.out.println(" Account service: 5 instances");
System.out.println(" Transfer service: 3 instances");
System.out.println(" Statement service: 10 instances");
System.out.println();
System.out.println(" Rajesh's request can hit ANY instance of ANY service");
System.out.println(" Because the JWT carries his identity");
System.out.println(" No shared session store needed");
System.out.println(" Scale up: just add instances. Scale down: just remove.");
}
}
Expected output:
Scalability with JWT:
Account service: 5 instances
Transfer service: 3 instances
Statement service: 10 instances
Rajesh's request can hit ANY instance of ANY service
Because the JWT carries his identity
No shared session store needed
What is the revocation tradeoff?
A JWT is self-contained and validated locally. There is no central place to invalidate it, so it stays valid until expiry.
package com.bank.session;
public class RevocationTradeoff {
public static void main(String[] args) {
System.out.println("Revocation problem:");
System.out.println(" Rajesh logs out, but his JWT has 5 minutes left");
System.out.println(" An attacker with the stolen token can use it for 5 minutes");
System.out.println();
System.out.println("Mitigations:");
System.out.println(" 1. Short-lived access tokens: 5 minutes");
System.out.println(" 2. Refresh tokens: revoke on logout -> no new access tokens");
System.out.println(" 3. Refresh rotation: detect reuse -> revoke entire family");
System.out.println(" 4. Denylist: for immediate revocation, check jti against small list");
}
}
Expected output:
Revocation problem:
Rajesh logs out, but his JWT has 5 minutes left
An attacker with the stolen token can use it for 5 minutes
Mitigations:
1. Short-lived access tokens: 5 minutes
2. Refresh tokens: revoke on logout -> no new access tokens
3. Refresh rotation: detect reuse -> revoke entire family
4. Denylist: for immediate revocation, check jti against small list
How does RS256 help in multi-service systems?
The auth service signs with a private key. Every service verifies with the public key from a JWKS endpoint. Services cannot mint tokens.
package com.bank.session;
public class Rs256Benefit {
public static void main(String[] args) {
System.out.println("RS256 in multi-service:");
System.out.println(" Auth service: holds PRIVATE key, signs tokens");
System.out.println(" Account service: fetches PUBLIC key from JWKS, verifies");
System.out.println(" Transfer service: fetches PUBLIC key from JWKS, verifies");
System.out.println();
System.out.println(" Benefit: services CANNOT mint tokens (no private key)");
System.out.println(" A compromised service can verify but not forge tokens");
}
}
Expected output:
RS256 in multi-service:
Auth service: holds PRIVATE key, signs tokens
Account service: fetches PUBLIC key from JWKS, verifies
Transfer service: fetches PUBLIC key from JWKS, verifies
Benefit: services CANNOT mint tokens (no private key)
A compromised service can verify but not forge tokens
When would you use a shared session store instead?
Rarely, but if you need immediate revocation on every request, a shared store like Redis gives you that at the cost of a dependency.
package com.bank.session;
public class WhenToUseRedisSession {
public static void main(String[] args) {
System.out.println("Use JWT (stateless) when:");
System.out.println(" - Standard microservices architecture");
System.out.println(" - Horizontal scaling is important");
System.out.println(" - 5-minute token TTL is acceptable for revocation");
System.out.println();
System.out.println("Use Redis session (stateful) when:");
System.out.println(" - Immediate revocation on EVERY request is required");
System.out.println(" - Regulatory requirement for instant session kill");
System.out.println(" - Accept the shared dependency and latency cost");
}
}
Expected output:
Use JWT (stateless) when:
- Standard microservices architecture
- Horizontal scaling is important
- 5-minute token TTL is acceptable for revocation
Use Redis session (stateful) when:
- Immediate revocation on EVERY request is required
- Regulatory requirement for instant session kill
- Accept the shared dependency and latency cost
What should a senior engineer know?
Never store sensitive data in the token. Always send over TLS. Validate audience to prevent cross-service replay. Propagate a correlation ID for tracing.
Senior guidelines:
[x] JWT for stateless, scalable authentication
[x] RS256 so services verify without holding signing secret
[x] Short-lived access tokens (5 min) + refresh rotation
[x] Validate iss, aud, exp on every request
[x] Never store sensitive data in payload (only encoded, not encrypted)
[x] Always over TLS (bearer credential)
[x] Propagate correlation/trace ID alongside token
[x] Optional denylist for forced immediate revocation
Visual Overview
Stateless Session with JWT:
┌──────────┐ login ┌──────────┐
│ Client │────────▶│ Auth Svc │ verify credentials
│ (mobile) │◀────────│ │ mint JWT
└──────┬───┘ JWT └──────────┘
│
│ Authorization: Bearer <jwt>
│
┌────▼─────┐ validate ┌──────────────┐
│ Account │ JWT │ Instance 1 │ any instance
│ Service │ locally │ Instance 2 │ serves any
│ │ │ Instance 3 │ request
└────┬─────┘ └──────────────┘
│
│ Authorization: Bearer <jwt> (same token)
│
┌────▼─────┐ validate
│ Transfer │ JWT
│ Service │ locally
└──────────┘
No server-side session anywhere.
Token carries the identity.
Any instance, any service.
Key Takeaway
Microservices do not maintain traditional server-side sessions. Instead, the client carries a signed JWT in the Authorization header on every request, and each service validates it locally by checking the signature, expiry, issuer, and audience. The token IS the session: the identity and roles that a server-side session would have stored now travel inside the signed token, so any instance of any service can handle any request.
The traditional model breaks in microservices because a session in one instance's memory is invisible to the others. A load balancer sends the next request to a different instance that has no session, so the user appears logged out. At National Bank of India, Rajesh was frustrated by intermittent logouts until Pankaj switched to stateless JWTs: Rajesh logged in once and was recognized on any instance of any service.
The honest tradeoff is revocation. A self-contained token is valid until it expires, so a stolen token is usable for its remaining lifetime. The mitigation is short-lived access tokens (5 minutes) paired with refresh tokens that are revoked on logout. For cases needing instant revocation, a small denylist of token IDs adds a little shared state back.
Remember one sentence: microservices stay stateless by having the client carry a signed JWT on every request and each service validate it locally, so there is no shared session, any instance serves any request, and the only tradeoff is revocation handled with short-lived tokens and refresh rotation.
---