The biggest challenge with JSON Web Tokens (JWT) is their stateless nature; once issued, they are valid until they naturally expire, leaving a massive security hole if a user logs out or a token is compromised. You can solve this by implementing a hybrid revocation strategy that uses a high-performance Redis blocklist to track invalidated jti (JWT ID) claims without sacrificing the speed of stateless architecture.
By the end of this guide, you will have a production-ready architecture for Spring Security JWT token revocation that ensures immediate session invalidation across distributed microservices.
TL;DR — Use a JwtValidator in Spring Security to check the jti claim against a Redis blocklist. Only tokens not found in Redis and with valid signatures are permitted. Set the Redis TTL to match the remaining life of the token to prevent memory leaks.
The Revocation Concept
💡 Analogy: Think of a standard JWT as a printed concert ticket. The bouncer (Resource Server) checks the holographic seal (Signature) and the date (Expiration). If they match, you get in. A revocation blocklist is like the bouncer holding a "Stolen Ticket List." Even if the ticket looks perfect, if the serial number (JTI) is on that list, the bouncer denies entry.
In a purely stateless OAuth2 environment, the Resource Server only validates the cryptographic signature of the JWT and the exp (expiration) claim. This is highly scalable because the server doesn't need to query a database for every request. However, this creates a "zombie token" problem: if a user's phone is stolen and they click "Log out of all devices," the old tokens remain functional until they expire.
To fix this, we introduce a Strict Revocation Layer. We assign every token a unique identifier called a jti. When a user logs out or a security event occurs, we store that jti in Redis. During every API call, Spring Security performs a "double-check": it verifies the signature (stateless) and then checks if the jti is in the Redis blocklist (stateful). Because Redis operates in-memory, this check typically adds less than 1ms of latency, maintaining the performance profile of your API.
When to Use Hybrid Revocation
You shouldn't implement this for every microservice. It is best suited for applications where the cost of a compromised session is high. In my experience scaling Spring Boot applications for fintech clients, the "stateless-only" approach often fails internal security audits because it doesn't support "Force Logout" or "Password Reset Invalidation."
Use this architecture if you meet any of the following criteria:
- Compliance Requirements: HIPAA, PCI-DSS, or GDPR often require the ability to immediately terminate access sessions.
- Long-Lived Access Tokens: If your access tokens last longer than 15 minutes, the window of vulnerability is too large without a kill-switch.
- Multi-Device Management: If you allow users to see and revoke active sessions from a dashboard.
If you are building a public-facing blog or a low-risk internal tool, the added complexity of managing a Redis cluster might outweigh the security benefits. In those cases, keep your token lifespans very short (5 minutes) and rely on Refresh Tokens for session continuity.
Architecture Structure and Flow
The system relies on three main components: the Authorization Server (which issues the jti), the Redis Blocklist (the store), and the Resource Server (which enforces the check). In a Spring Boot 3.x environment using Spring Security 6, the flow looks like this:
[ Client ] -> Bearer Token (JTI: "abc-123") -> [ API Gateway / Resource Server ]
|
[ 1. Validate Signature ]
|
[ 2. Check Redis for "abc-123" ]
|
[ Protected Resource ] <- [ Allow Access ] <----------- [ Result: Not Found ]
The data flow for revocation is equally important. When a logout occurs, the application calculates the remaining time until the token's exp. It then saves the jti to Redis with that remaining time as the Time-to-Live (TTL). This ensures the blocklist doesn't grow infinitely; once a token would have naturally expired, it is automatically removed from the blocklist because it is no longer a threat anyway.
Step-by-Step Implementation
To implement this in Spring Boot 3.3.x, you need the spring-boot-starter-oauth2-resource-server and spring-boot-starter-data-redis dependencies. Ensure your Authorization Server is configured to include the jti claim in all issued tokens.
Step 1: Create the Blocklist Service
First, we need a service to interact with Redis. We will use a simple string-based approach where the key is the jti and the value is a timestamp or a simple "revoked" string.
@Service
public class TokenBlocklistService {
private final StringRedisTemplate redisTemplate;
public TokenBlocklistService(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void blockToken(String jti, long durationInSeconds) {
redisTemplate.opsForValue().set(jti, "revoked", Duration.ofSeconds(durationInSeconds));
}
public boolean isBlocklisted(String jti) {
return Boolean.TRUE.equals(redisTemplate.hasKey(jti));
}
}
Step 2: Custom JWT Validator
Spring Security allows you to hook into the JWT validation pipeline. We will create a class that implements OAuth2TokenValidator<Jwt>. This is the heart of the OAuth2 implementation for revocation.
public class TokenRevocationValidator implements OAuth2TokenValidator<Jwt> {
private final TokenBlocklistService blocklistService;
public TokenRevocationValidator(TokenBlocklistService blocklistService) {
this.blocklistService = blocklistService;
}
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
String jti = jwt.getId();
if (jti == null || blocklistService.isBlocklisted(jti)) {
OAuth2Error error = new OAuth2Error("invalid_token", "The token has been revoked", null);
return OAuth2TokenValidatorResult.failure(error);
}
return OAuth2TokenValidatorResult.success();
}
}
Step 3: Register the Validator in SecurityConfig
Finally, you must wire this validator into the JwtDecoder. When I first implemented this, I missed the fact that you must combine your custom validator with the default timestamp validator to ensure you don't break standard checks.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, JwtDecoder jwtDecoder) throws Exception {
http
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.decoder(jwtDecoder))
)
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
return http.build();
}
@Bean
public JwtDecoder jwtDecoder(TokenBlocklistService blocklistService, OAuth2ResourceServerProperties properties) {
NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(properties.getJwt().getIssuerUri()).build();
OAuth2TokenValidator<Jwt> withTimestamp = JwtValidators.createDefaultWithIssuer(properties.getJwt().getIssuerUri());
OAuth2TokenValidator<Jwt> withRevocation = new TokenRevocationValidator(blocklistService);
// Combine validators
OAuth2TokenValidator<Jwt> combinedValidator = new DelegatingOAuth2TokenValidator<>(withTimestamp, withRevocation);
jwtDecoder.setJwtValidator(combinedValidator);
return jwtDecoder;
}
}
Security vs. Performance Trade-offs
Implementing strict revocation shifts your system from a "Pure Stateless" model to a "Stateful-Validation" model. While Redis is incredibly fast, you are adding a network hop to every single authenticated request. Understanding these trade-offs is key to a successful API security strategy.
| Feature | Standard Stateless JWT | Hybrid Redis Blocklist | Impact |
|---|---|---|---|
| Latency | Lowest (CPU only) | Low (+0.5ms to 2ms) | Minimal for most apps |
| Revocation Speed | Delayed (wait for exp) |
Instant | Critical for security |
| Scalability | Infinite | Limited by Redis I/O | Requires Redis Cluster |
| Storage Cost | Zero | Proportional to active sessions | Managed by TTL |
⚠️ Common Mistake: Forgetting to handle Redis downtime. If your Redis cluster goes down, your JwtValidator might throw an exception, causing a site-wide outage (Fail-Closed) or allowing revoked tokens (Fail-Open). In high-security environments, "Fail-Closed" is the standard, but you must have a high-availability Redis setup (Sentinel or Cluster) to mitigate this.
Pro-Tips for Production
When deploying this in a real-world environment, consider the following metric-backed optimizations to keep your Spring Security JWT implementation lean:
- Use Local Caching: To further reduce Redis load, implement a tiny Caffeine cache (e.g., 5-second TTL) in front of the
isBlocklistedcheck. This handles "bursty" clients that hit multiple endpoints in one second. - Audit Logging: Whenever a
jtiis found in the blocklist, log it with theWARNlevel. This often indicates a replay attack or a user attempting to use a leaked token. - Graceful Degradation: If you use a "Fail-Open" strategy for availability, ensure you have secondary monitoring to alert when Redis becomes unreachable.
📌 Key Takeaways
- Strict revocation requires a unique
jticlaim in your JWTs. - Redis provides the necessary speed to check token validity on every request.
- Automatic TTL management in Redis prevents memory bloat by expiring blocklist entries alongside the original token.
- Combining custom validators with Spring Security's
DelegatingOAuth2TokenValidatoris the cleanest architectural approach.
Frequently Asked Questions
Q. How to revoke a JWT token in Spring Boot?
A. You cannot "delete" a JWT from the client remotely. Instead, you must store the token's unique identifier (JTI) in a server-side blocklist (like Redis) and configure Spring Security to reject any incoming tokens whose JTI appears in that list until the token's natural expiration time is reached.
Q. Is checking Redis on every request too slow for high-traffic APIs?
A. For most applications, no. Redis typically responds in sub-millisecond time. If you handle millions of requests per second, you can use a near-cache (local memory) to store the most active "revoked" IDs for a few seconds to prevent unnecessary network round-trips to Redis.
Q. What happens to the blocklist when a token expires?
A. By setting the Redis TTL (Time-to-Live) to the remaining duration of the JWT at the moment of revocation, the entry automatically disappears from Redis once the token is naturally invalid. This keeps your Redis memory usage efficient and prevents the need for manual cleanup scripts.
Post a Comment