Article
In modern distributed systems, tokens (JWT, OAuth2, API keys) have become the standard for authentication and authorization. However, one of the biggest challenges with token-based authentication is revocation—the ability to invalidate a token before its natural expiration. Whether a user logs out, changes their password, or is removed from the system, you need a reliable way to ensure that existing tokens can no longer be used. This article explores comprehensive strategies for implementing token revocation in Java applications.
Why Token Revocation Matters
- Security Incidents: Immediately invalidate tokens after a breach
- User Logout: Ensure tokens cannot be reused after logout
- Permission Changes: Revoke tokens when user roles or permissions change
- Account Termination: Immediately block access for removed users
- Password Rotation: Invalidate old tokens after password changes
- Device Management: Allow users to revoke specific device tokens
Token Revocation Strategies
There are several approaches to token revocation, each with different trade-offs:
| Strategy | Pros | Cons | Use Case |
|---|---|---|---|
| Blacklist | Simple, immediate | Stateful, storage overhead | High-security apps |
| Whitelist | Positive validation | More stateful | Session-based systems |
| Short-lived Tokens | Stateless, simple | Refresh token needed | Low-risk APIs |
| Versioned Claims | Minimal storage | Requires claim updates | Permission-based systems |
| Distributed Cache | Fast, scalable | Infrastructure dependency | Microservices |
1. Token Blacklist Implementation
Basic Blacklist Service
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.TimeUnit;
/**
* Token blacklist for immediate revocation
*/
@Service
public class TokenBlacklistService {
private final RedisTemplate<String, String> redisTemplate;
public TokenBlacklistService(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* Revoke a token by adding it to the blacklist
*/
public void revokeToken(String tokenId, Duration ttl) {
String key = "blacklist:token:" + tokenId;
redisTemplate.opsForValue().set(key, "revoked", ttl.toSeconds(), TimeUnit.SECONDS);
}
/**
* Revoke all tokens for a user
*/
public void revokeAllUserTokens(String userId) {
String key = "blacklist:user:" + userId + ":token-version";
// Increment token version to invalidate all existing tokens
redisTemplate.opsForValue().increment(key);
}
/**
* Check if a token is revoked
*/
public boolean isTokenRevoked(String tokenId) {
String key = "blacklist:token:" + tokenId;
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
}
/**
* Get current token version for a user
*/
public long getTokenVersion(String userId) {
String key = "blacklist:user:" + userId + ":token-version";
String value = redisTemplate.opsForValue().get(key);
return value == null ? 0L : Long.parseLong(value);
}
}
JWT Filter with Blacklist Check
@Component
public class TokenBlacklistFilter extends OncePerRequestFilter {
@Autowired
private TokenBlacklistService blacklistService;
@Autowired
private JwtTokenProvider tokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {
String token = extractToken(request);
if (token != null) {
try {
String tokenId = tokenProvider.getTokenId(token);
// Check if token is blacklisted
if (blacklistService.isTokenRevoked(tokenId)) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.getWriter().write("Token has been revoked");
return;
}
// Check token version (for user-level revocation)
String userId = tokenProvider.getUserId(token);
long tokenVersion = tokenProvider.getTokenVersion(token);
long currentVersion = blacklistService.getTokenVersion(userId);
if (tokenVersion < currentVersion) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.getWriter().write("Token version outdated");
return;
}
} catch (Exception e) {
logger.error("Token validation failed", e);
}
}
chain.doFilter(request, response);
}
private String extractToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
2. In-Memory Blacklist (for smaller deployments)
@Component
public class InMemoryTokenBlacklist {
private final Map<String, RevokedTokenInfo> blacklist = new ConcurrentHashMap<>();
private final ScheduledExecutorService cleaner = Executors.newScheduledThreadPool(1);
@PostConstruct
public void init() {
cleaner.scheduleAtFixedRate(this::cleanExpiredTokens, 1, 1, TimeUnit.HOURS);
}
@PreDestroy
public void destroy() {
cleaner.shutdown();
}
public void revokeToken(String tokenId, Instant expirationTime) {
blacklist.put(tokenId, new RevokedTokenInfo(tokenId, expirationTime));
}
public boolean isRevoked(String tokenId) {
RevokedTokenInfo info = blacklist.get(tokenId);
if (info == null) {
return false;
}
if (info.expirationTime.isBefore(Instant.now())) {
blacklist.remove(tokenId);
return false;
}
return true;
}
private void cleanExpiredTokens() {
Instant now = Instant.now();
blacklist.entrySet().removeIf(entry ->
entry.getValue().expirationTime.isBefore(now));
}
@lombok.Value
private static class RevokedTokenInfo {
String tokenId;
Instant expirationTime;
}
}
3. Database-Backed Revocation
JPA Entity for Revoked Tokens
@Entity
@Table(name = "revoked_tokens",
indexes = {
@Index(name = "idx_token_id", columnList = "tokenId"),
@Index(name = "idx_expiry", columnList = "expiryDate")
})
public class RevokedToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 512)
private String tokenId;
@Column(nullable = false)
private String tokenType; // ACCESS, REFRESH, etc.
@Column(nullable = false)
private String userId;
@Column(nullable = false)
private Instant revokedAt;
@Column(nullable = false)
private Instant expiryDate;
@Column(length = 500)
private String revocationReason;
// Getters and setters
}
Repository and Service
@Repository
public interface RevokedTokenRepository extends JpaRepository<RevokedToken, Long> {
boolean existsByTokenId(String tokenId);
@Modifying
@Query("DELETE FROM RevokedToken t WHERE t.expiryDate < :now")
int deleteExpiredTokens(@Param("now") Instant now);
List<RevokedToken> findByUserId(String userId);
@Modifying
@Query("DELETE FROM RevokedToken t WHERE t.userId = :userId")
int deleteAllForUser(@Param("userId") String userId);
}
@Service
@Transactional
public class DatabaseTokenRevocationService {
@Autowired
private RevokedTokenRepository repository;
@Autowired
private JwtTokenProvider tokenProvider;
public void revokeToken(String token, String reason) {
String tokenId = tokenProvider.getTokenId(token);
String userId = tokenProvider.getUserId(token);
Instant expiry = tokenProvider.getExpirationTime(token);
RevokedToken revokedToken = new RevokedToken();
revokedToken.setTokenId(tokenId);
revokedToken.setTokenType(tokenProvider.getTokenType(token));
revokedToken.setUserId(userId);
revokedToken.setRevokedAt(Instant.now());
revokedToken.setExpiryDate(expiry);
revokedToken.setRevocationReason(reason);
repository.save(revokedToken);
}
public void revokeAllUserTokens(String userId, String reason) {
List<RevokedToken> activeTokens = repository.findByUserId(userId);
// Implementation depends on how you track active tokens
}
public boolean isTokenRevoked(String tokenId) {
return repository.existsByTokenId(tokenId);
}
@Scheduled(cron = "0 0 2 * * ?") // Run at 2 AM daily
public void cleanExpiredTokens() {
repository.deleteExpiredTokens(Instant.now());
}
}
4. JWT with Revocation Claims
Enhanced JWT Token Provider
@Component
public class RevocableJwtTokenProvider {
@Value("${app.jwt.secret}")
private String jwtSecret;
@Value("${app.jwt.expiration}")
private int jwtExpiration;
@Autowired
private TokenBlacklistService blacklistService;
public String generateToken(String userId, Set<String> roles) {
String tokenId = UUID.randomUUID().toString();
long tokenVersion = blacklistService.getTokenVersion(userId);
Date now = new Date();
Date expiryDate = new Date(now.getTime() + jwtExpiration);
return Jwts.builder()
.setId(tokenId)
.setSubject(userId)
.claim("roles", roles)
.claim("tokenVersion", tokenVersion)
.claim("tokenType", "ACCESS")
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(SignatureAlgorithm.HS512, jwtSecret)
.compact();
}
public boolean validateToken(String token) {
try {
Claims claims = Jwts.parser()
.setSigningKey(jwtSecret)
.parseClaimsJws(token)
.getBody();
String tokenId = claims.getId();
String userId = claims.getSubject();
long tokenVersion = claims.get("tokenVersion", Long.class);
// Check blacklist
if (blacklistService.isTokenRevoked(tokenId)) {
return false;
}
// Check version
long currentVersion = blacklistService.getTokenVersion(userId);
if (tokenVersion < currentVersion) {
return false;
}
return true;
} catch (Exception e) {
return false;
}
}
public void revokeToken(String token) {
Claims claims = Jwts.parser()
.setSigningKey(jwtSecret)
.parseClaimsJws(token)
.getBody();
String tokenId = claims.getId();
Date expiration = claims.getExpiration();
blacklistService.revokeToken(
tokenId,
Duration.between(Instant.now(), expiration.toInstant())
);
}
}
5. Distributed Revocation with Redis Pub/Sub
Redis Revocation Publisher
@Service
public class RedisRevocationPublisher {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private static final String CHANNEL = "token-revocation";
public void publishRevocation(String tokenId, String userId, String reason) {
RevocationEvent event = RevocationEvent.builder()
.tokenId(tokenId)
.userId(userId)
.reason(reason)
.timestamp(Instant.now())
.build();
redisTemplate.convertAndSend(CHANNEL, event);
}
@Data
@Builder
public static class RevocationEvent {
private String tokenId;
private String userId;
private String reason;
private Instant timestamp;
}
}
Revocation Subscriber
@Component
public class RedisRevocationSubscriber {
private static final Logger logger = LoggerFactory.getLogger(RedisRevocationSubscriber.class);
@Autowired
private TokenBlacklistService blacklistService;
@Autowired
private DistributedCacheService cacheService;
@EventListener
public void handleMessage(Message message) {
try {
RevocationEvent event = new ObjectMapper()
.readValue(message.getBody(), RevocationEvent.class);
logger.info("Received revocation event: {}", event);
// Apply revocation locally
blacklistService.revokeToken(
event.getTokenId(),
Duration.ofHours(24) // Configure based on token expiry
);
// Clear any cached user data
cacheService.evictUserCache(event.getUserId());
} catch (Exception e) {
logger.error("Failed to process revocation event", e);
}
}
}
6. REST API for Token Revocation
@RestController
@RequestMapping("/api/auth/tokens")
public class TokenRevocationController {
@Autowired
private TokenRevocationService revocationService;
@Autowired
private CurrentUserService currentUserService;
/**
* Revoke the current token (logout)
*/
@PostMapping("/revoke/current")
public ResponseEntity<?> revokeCurrentToken(
@RequestHeader("Authorization") String authHeader) {
String token = authHeader.replace("Bearer ", "");
revocationService.revokeToken(token, "User logout");
return ResponseEntity.ok(new ApiResponse("Token revoked successfully"));
}
/**
* Revoke a specific token by ID (admin or user)
*/
@PostMapping("/revoke/{tokenId}")
@PreAuthorize("hasRole('ADMIN') or @tokenSecurityService.isTokenOwner(#tokenId)")
public ResponseEntity<?> revokeToken(
@PathVariable String tokenId,
@RequestParam(required = false) String reason) {
revocationService.revokeTokenById(tokenId, reason);
return ResponseEntity.ok(new ApiResponse("Token revoked"));
}
/**
* Revoke all tokens for a user (password change, account lock)
*/
@PostMapping("/revoke/user/{userId}")
@PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.id")
public ResponseEntity<?> revokeAllUserTokens(
@PathVariable String userId,
@RequestParam String reason) {
revocationService.revokeAllUserTokens(userId, reason);
return ResponseEntity.ok(new ApiResponse("All user tokens revoked"));
}
/**
* List revoked tokens (audit purposes)
*/
@GetMapping("/revoked")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<List<RevokedTokenInfo>> getRevokedTokens(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size) {
return ResponseEntity.ok(revocationService.getRevokedTokens(page, size));
}
}
7. Automatic Cleanup Service
@Component
public class TokenRevocationCleanupService {
private static final Logger logger = LoggerFactory.getLogger(TokenRevocationCleanupService.class);
@Autowired
private TokenBlacklistService blacklistService;
@Autowired
private RevokedTokenRepository repository;
/**
* Clean expired tokens from blacklist
*/
@Scheduled(fixedDelay = 3600000) // Every hour
public void cleanExpiredBlacklistEntries() {
logger.info("Starting expired token cleanup");
// Implementation depends on your storage
// For Redis, keys have TTL so automatic
// For database, use the cleanup method
int cleaned = repository.deleteExpiredTokens(Instant.now());
logger.info("Cleaned {} expired revoked tokens", cleaned);
}
/**
* Audit long-standing revoked tokens
*/
@Scheduled(cron = "0 0 1 * * ?") // Daily at 1 AM
public void auditRevokedTokens() {
Instant thirtyDaysAgo = Instant.now().minus(30, ChronoUnit.DAYS);
List<RevokedToken> oldRevokedTokens =
repository.findByRevokedAtBefore(thirtyDaysAgo);
if (!oldRevokedTokens.isEmpty()) {
logger.warn("Found {} revoked tokens older than 30 days",
oldRevokedTokens.size());
// Archive or notify security team
for (RevokedToken token : oldRevokedTokens) {
logger.debug("Old revoked token: {}", token.getTokenId());
}
}
}
}
8. Token Revocation for OAuth2
@Service
public class OAuth2TokenRevocationService {
@Autowired
private TokenStore tokenStore;
@Autowired
private TokenBlacklistService blacklistService;
/**
* OAuth2 token revocation endpoint (RFC 7009)
*/
@PostMapping("/oauth/revoke")
public ResponseEntity<?> revokeOAuth2Token(
@RequestParam("token") String token,
@RequestParam(value = "token_type_hint", required = false)
String tokenTypeHint) {
OAuth2AccessToken accessToken = tokenStore.readAccessToken(token);
OAuth2RefreshToken refreshToken = null;
if (accessToken != null) {
// Revoke access token
tokenStore.removeAccessToken(accessToken);
// Also revoke associated refresh token if exists
refreshToken = accessToken.getRefreshToken();
if (refreshToken != null) {
tokenStore.removeRefreshToken(refreshToken);
}
// Add to blacklist for immediate effect
blacklistService.revokeToken(
accessToken.getValue(),
Duration.ofHours(1)
);
logger.info("Revoked OAuth2 token for client: {}",
accessToken.getClientId());
}
// OAuth2 revocation returns 200 even if token doesn't exist
return ResponseEntity.ok(new ApiResponse("Token revoked"));
}
/**
* Handle token revocation for specific client
*/
@PostMapping("/oauth/revoke/client/{clientId}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> revokeAllClientTokens(@PathVariable String clientId) {
Collection<OAuth2AccessToken> tokens =
tokenStore.findTokensByClientId(clientId);
for (OAuth2AccessToken token : tokens) {
tokenStore.removeAccessToken(token);
if (token.getRefreshToken() != null) {
tokenStore.removeRefreshToken(token.getRefreshToken());
}
}
logger.info("Revoked {} tokens for client: {}", tokens.size(), clientId);
return ResponseEntity.ok(new ApiResponse(
String.format("Revoked %d tokens for client %s",
tokens.size(), clientId)
));
}
}
9. Webhook-Based Revocation for Microservices
@Service
public class WebhookRevocationService {
private static final Logger logger = LoggerFactory.getLogger(WebhookRevocationService.class);
@Autowired
private RestTemplate restTemplate;
@Value("${revocation.webhook.urls}")
private List<String> webhookUrls;
/**
* Notify all services about token revocation
*/
public void notifyRevocation(RevocationEvent event) {
List<CompletableFuture<Void>> futures = new ArrayList<>();
for (String url : webhookUrls) {
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
try {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<RevocationEvent> request =
new HttpEntity<>(event, headers);
restTemplate.postForEntity(
url + "/api/revocation/webhook",
request,
String.class
);
} catch (Exception e) {
logger.error("Failed to notify {} about revocation", url, e);
}
});
futures.add(future);
}
// Wait for all notifications (with timeout)
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
.orTimeout(5, TimeUnit.SECONDS)
.exceptionally(throwable -> {
logger.warn("Some revocation notifications timed out");
return null;
});
}
@Data
@Builder
public static class RevocationEvent {
private String tokenId;
private String userId;
private String reason;
private Instant timestamp;
private String service;
}
}
10. Client-Side Token Management
@Service
public class ClientTokenManager {
@Autowired
private RestTemplate restTemplate;
private final Set<String> revokedTokens = Collections.newSetFromMap(
new ConcurrentHashMap<>());
/**
* Check with auth server if token is still valid
*/
public boolean isTokenValid(String token) {
// Fast check against local cache
if (revokedTokens.contains(token)) {
return false;
}
try {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + token);
ResponseEntity<TokenStatus> response = restTemplate.exchange(
"http://auth-server/api/auth/validate",
HttpMethod.GET,
new HttpEntity<>(headers),
TokenStatus.class
);
return response.getBody() != null &&
response.getBody().isValid();
} catch (HttpClientErrorException.Unauthorized e) {
// Token is invalid or revoked
revokedTokens.add(token);
return false;
} catch (Exception e) {
// Auth server unavailable - assume valid (or implement fallback)
logger.warn("Auth server unavailable, assuming token valid");
return true;
}
}
@Data
private static class TokenStatus {
private boolean valid;
private String userId;
private Instant expiresAt;
}
}
Configuration Properties
# application.yml app: token: revocation: strategy: redis # redis, database, memory, hybrid # Redis configuration redis: enabled: true ttl-hours: 24 # Database configuration database: cleanup-cron: "0 0 2 * * ?" archive-after-days: 30 # Memory configuration memory: max-size: 10000 cleanup-interval-minutes: 60 # Hybrid configuration (multiple strategies) hybrid: primary: redis secondary: database # Webhook configuration webhooks: enabled: true urls: - http://service-a:8080/api/revocation - http://service-b:8080/api/revocation timeout-seconds: 3 # Token versioning versioning: enabled: true claim-name: "tokenVersion" # Cache settings cache: enabled: true max-size: 100000 ttl-minutes: 5
Best Practices
- Layered Revocation: Implement multiple revocation mechanisms
- Immediate vs Eventually Consistent: Choose based on security requirements
- Storage Considerations: Balance between speed and persistence
- Monitoring: Track revocation rates and blacklist size
- Audit Trail: Log all revocation events for security analysis
- Graceful Degradation: Handle auth server unavailability
- Token Versioning: Simple way to revoke all user tokens
- Cleanup Strategy: Prevent unbounded growth of revocation data
- Performance Testing: Ensure revocation checks don't become bottlenecks
- Fallback Mechanisms: Plan for component failures
Conclusion
Token revocation is a critical security feature that turns static tokens into manageable, revocable credentials. By implementing robust revocation mechanisms, Java applications can respond to security events, respect user actions, and maintain fine-grained control over access.
Key takeaways:
- Multiple strategies suit different use cases and scale requirements
- Redis provides fast, distributed revocation for high-scale systems
- Database offers persistence and audit capabilities
- Token versioning enables user-wide revocation with minimal storage
- Hybrid approaches combine the benefits of multiple strategies
For modern applications, token revocation is not optional—it's essential for security, compliance, and user experience. By carefully designing and implementing revocation mechanisms, you ensure that your authentication system remains under your control, even after tokens have been issued.
Java Programming Intermediate Topics – Modifiers, Loops, Math, Methods & Projects (Related to Java Programming)
Access Modifiers in Java:
Access modifiers control how classes, variables, and methods are accessed from different parts of a program. Java provides four main access levels—public, private, protected, and default—which help protect data and control visibility in object-oriented programming.
Read more: https://macronepal.com/blog/access-modifiers-in-java-a-complete-guide/
Static Variables in Java:
Static variables belong to the class rather than individual objects. They are shared among all instances of the class and are useful for storing values that remain common across multiple objects.
Read more: https://macronepal.com/blog/static-variables-in-java-a-complete-guide/
Method Parameters in Java:
Method parameters allow values to be passed into methods so that operations can be performed using supplied data. They help make methods flexible and reusable in different parts of a program.
Read more: https://macronepal.com/blog/method-parameters-in-java-a-complete-guide/
Random Numbers in Java:
This topic explains how to generate random numbers in Java for tasks such as simulations, games, and random selections. Random numbers help create unpredictable results in programs.
Read more: https://macronepal.com/blog/random-numbers-in-java-a-complete-guide/
Math Class in Java:
The Math class provides built-in methods for performing mathematical calculations such as powers, square roots, rounding, and other advanced calculations used in Java programs.
Read more: https://macronepal.com/blog/math-class-in-java-a-complete-guide/
Boolean Operations in Java:
Boolean operations use true and false values to perform logical comparisons. They are commonly used in conditions and decision-making statements to control program flow.
Read more: https://macronepal.com/blog/boolean-operations-in-java-a-complete-guide/
Nested Loops in Java:
Nested loops are loops placed inside other loops to perform repeated operations within repeated tasks. They are useful for pattern printing, tables, and working with multi-level data.
Read more: https://macronepal.com/blog/nested-loops-in-java-a-complete-guide/
Do-While Loop in Java:
The do-while loop allows a block of code to run at least once before checking the condition. It is useful when the program must execute a task before verifying whether it should continue.
Read more: https://macronepal.com/blog/do-while-loop-in-java-a-complete-guide/
Simple Calculator Project in Java:
This project demonstrates how to create a basic calculator program using Java. It combines input handling, arithmetic operations, and conditional logic to perform simple mathematical calculations.
Read more: https://macronepal.com/blog/simple-calculator-project-in-java/