Revocation Endpoint and Token State Management in Spring Authorization Server
Revocation Endpoint and Token State Management
The Assumption
Token revocation is a DELETE call. The assumption: once the authorization server marks a token as revoked, it stops working everywhere.
For opaque tokens validated via introspection, this is true. For JWTs validated locally by resource servers using cached public keys, revocation at the authorization server changes nothing. The JWT signature remains valid. The claims remain unexpired. Every resource server that validated locally continues to accept the token until its exp claim passes.
This is not a bug in the implementation. It is a fundamental property of stateless tokens. You chose JWTs for their statelessness (no round-trip to the auth server on every request). The cost of statelessness is that revocation requires additional infrastructure.
The Attack
Stolen access token used within its TTL window. An attacker exfiltrates an access token (via XSS, log exposure, or network interception). The security team detects the compromise and revokes all tokens for the affected user at the authorization server. The attacker continues using the stolen access token for the remaining 4 minutes and 30 seconds of its 5-minute TTL. Every resource server accepts the token because they validate locally.
The severity depends on the access token TTL:
- 1-minute TTL: Attacker has 1 minute of access post-revocation. Likely insufficient to exfiltrate significant data.
- 5-minute TTL: Attacker has up to 5 minutes. Enough to enumerate tenant resources and export data.
- 60-minute TTL: Attacker has up to 60 minutes. Game over for any sensitive system.
This is why access token TTL is a security parameter, not a convenience parameter.
The Spec or Mechanism
RFC 7009 defines the token revocation endpoint. Key behaviors:
- The client sends a POST to the revocation endpoint with the token and an optional
token_type_hint. - The authorization server validates client authentication (the same client that obtained the token, or a privileged client).
- The server invalidates the token. If a refresh token is revoked, the associated access token SHOULD also be revoked.
- The response is always 200 OK, regardless of whether the token was valid, already revoked, or unknown. This prevents token probing (an attacker cannot determine if a token value is valid by observing the revocation response).
POST /oauth2/revoke HTTP/1.1
Host: auth.saas.example
Content-Type: application/x-www-form-urlencoded
Authorization: Basic ZnJvbnRlbmQtc2hlbGw6c2VjcmV0
token=eyJhbGciOiJSUzI1NiJ9...&token_type_hint=access_token
Response (always):
HTTP/1.1 200 OK
The token_type_hint is a performance optimization. Without it, the server checks all token types (access token, refresh token) sequentially. With the hint, it checks the hinted type first.
The Implementation
Spring Authorization Server Revocation Endpoint
Spring Authorization Server exposes the revocation endpoint at /oauth2/revoke by default. Configuration:
@Bean
public SecurityFilterChain authorizationServerSecurityFilterChain(
HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.tokenRevocationEndpoint(revocation -> revocation
.revocationResponseHandler((request, response, authentication) -> {
// Custom post-revocation logic
OAuth2TokenRevocationAuthenticationToken revocationAuth =
(OAuth2TokenRevocationAuthenticationToken) authentication;
String tokenValue = revocationAuth.getToken();
// Propagate revocation to resource servers
revocationPropagator.propagate(tokenValue);
response.setStatus(HttpServletResponse.SC_OK);
})
);
return http.build();
}
Revocation Propagation Strategies
The gap between “authorization server knows token is revoked” and “resource servers reject the token” must be closed. Three strategies, each with different latency-cost tradeoffs:
Strategy 1: Short TTL (accept the gap). Access tokens expire in 5 minutes or less. Revocation at the auth server prevents new tokens from being issued. Existing tokens are accepted for their remaining lifetime. The maximum exposure window equals the access token TTL.
// No additional infrastructure needed.
// Accept that revocation is not immediate for JWTs.
// Configure short TTL as primary defense.
.tokenSettings(TokenSettings.builder()
.accessTokenTimeToLive(Duration.ofMinutes(5))
.build())
Strategy 2: Revocation list in Redis (near-real-time).
The authorization server publishes revoked token identifiers (the jti claim) to a Redis set. Resource servers check this set during JWT validation. Adds one Redis lookup per request but provides sub-second revocation propagation.
// HARDENED: Resource server with revocation check
@Bean
public JwtDecoder jwtDecoder(RedisTemplate<String, String> redisTemplate) {
NimbusJwtDecoder decoder = NimbusJwtDecoder
.withJwkSetUri("https://auth.saas.example/oauth2/jwks")
.jwsAlgorithm(SignatureAlgorithm.RS256)
.build();
OAuth2TokenValidator<Jwt> revocationValidator = token -> {
String jti = token.getId();
if (jti == null) {
return OAuth2TokenValidatorResult.failure(
new OAuth2Error("missing_jti", "Token has no jti claim", null));
}
Boolean revoked = redisTemplate.opsForSet()
.isMember("revoked_tokens", jti);
if (Boolean.TRUE.equals(revoked)) {
return OAuth2TokenValidatorResult.failure(
new OAuth2Error("token_revoked", "Token has been revoked", null));
}
return OAuth2TokenValidatorResult.success();
};
OAuth2TokenValidator<Jwt> defaultValidators =
JwtValidators.createDefaultWithIssuer("https://auth.saas.example");
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
defaultValidators, revocationValidator));
return decoder;
}
// Authorization server: publish revocation to Redis
@Component
public class RedisRevocationPropagator {
private final RedisTemplate<String, String> redisTemplate;
public void propagate(String tokenValue, Duration ttl) {
// Parse the JWT to extract jti (we only need the payload, not validation)
String[] parts = tokenValue.split("\\.");
String payload = new String(Base64.getUrlDecoder().decode(parts[1]));
String jti = JsonPath.read(payload, "$.jti");
// Add to revoked set with TTL matching the token's remaining lifetime
redisTemplate.opsForSet().add("revoked_tokens", jti);
// Set expiry on the key slightly after token's exp to allow cleanup
redisTemplate.expire("revoked_tokens:" + jti, ttl.plusMinutes(1));
}
}
Strategy 3: Token introspection fallback (guaranteed revocation). For high-security operations, the resource server calls the introspection endpoint instead of (or in addition to) local JWT validation. Every request incurs a round-trip to the auth server, eliminating the revocation gap entirely.
// HARDENED: Hybrid validation with introspection for sensitive operations
@Bean
public SecurityFilterChain highSecurityChain(HttpSecurity http) throws Exception {
return http
.securityMatcher("/api/admin/**", "/api/billing/**")
.oauth2ResourceServer(oauth2 -> oauth2
.opaqueToken(opaque -> opaque
.introspectionUri("https://auth.saas.example/oauth2/introspect")
.introspectionClientCredentials("core-api", "introspection-secret")
)
)
.build();
}
// Standard validation for non-sensitive operations
@Bean
public SecurityFilterChain standardChain(HttpSecurity http) throws Exception {
return http
.securityMatcher("/api/**")
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.decoder(jwtDecoder()))
)
.build();
}
OAuth2AuthorizationService: JDBC vs Redis
The OAuth2AuthorizationService stores the authorization state (grant, tokens, metadata). Two production-ready implementations:
// JDBC: Durable, survives restarts, suitable for single-region deployments
@Bean
public OAuth2AuthorizationService jdbcAuthorizationService(
JdbcOperations jdbcOperations,
RegisteredClientRepository registeredClientRepository) {
JdbcOAuth2AuthorizationService service = new JdbcOAuth2AuthorizationService(
jdbcOperations, registeredClientRepository);
// Custom row mapper for additional metadata
JdbcOAuth2AuthorizationService.OAuth2AuthorizationRowMapper rowMapper =
new JdbcOAuth2AuthorizationService.OAuth2AuthorizationRowMapper(
registeredClientRepository);
service.setAuthorizationRowMapper(rowMapper);
return service;
}
// Redis: Fast, supports distributed deployments, requires separate persistence strategy
@Bean
public OAuth2AuthorizationService redisAuthorizationService(
RedisTemplate<String, byte[]> redisTemplate) {
return new RedisOAuth2AuthorizationService(redisTemplate);
}
The choice depends on deployment topology:
- Single instance or single-region with shared database: JDBC with PostgreSQL.
- Multi-region or high-throughput (>10,000 token operations/second): Redis with persistence (RDB + AOF).
- Hybrid: JDBC as source of truth, Redis as cache with write-through.
The Verification
# 1. Obtain a token
ACCESS_TOKEN=$(curl -s -X POST https://auth.saas.example/oauth2/token \
-u frontend-shell:secret \
-d "grant_type=authorization_code&code=abc123&redirect_uri=https://app.saas.example/callback" \
| jq -r '.access_token')
# 2. Verify token works
curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
https://api.saas.example/api/projects \
| jq '.status'
# Expected: 200 with project data
# 3. Revoke the token
curl -s -X POST https://auth.saas.example/oauth2/revoke \
-u frontend-shell:secret \
-d "token=$ACCESS_TOKEN&token_type_hint=access_token"
# Expected: 200 OK (always)
# 4. Verify token is rejected (with revocation list strategy)
curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
https://api.saas.example/api/projects
# Expected: 401
# 5. Verify introspection confirms revocation
curl -s -X POST https://auth.saas.example/oauth2/introspect \
-u core-api:introspection-secret \
-d "token=$ACCESS_TOKEN" \
| jq '.active'
# Expected: false
@SpringBootTest
@AutoConfigureMockMvc
class TokenRevocationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Test
void revokedTokenIsRejectedByResourceServer() throws Exception {
// Issue a token with known jti
String jti = "test-token-" + UUID.randomUUID();
Jwt jwt = Jwt.withTokenValue("test")
.header("alg", "RS256")
.claim("sub", "alice")
.claim("tenant_id", "acme-corp")
.claim("jti", jti)
.claim("aud", List.of("core-api"))
.claim("iss", "https://auth.saas.example")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(300))
.build();
// Verify token works before revocation
mockMvc.perform(get("/api/projects")
.with(jwt().jwt(jwt)))
.andExpect(status().isOk());
// Simulate revocation: add jti to revoked set
redisTemplate.opsForSet().add("revoked_tokens", jti);
// Verify token is now rejected
mockMvc.perform(get("/api/projects")
.with(jwt().jwt(jwt)))
.andExpect(status().isUnauthorized());
}
@Test
void revocationResponseIsAlways200() throws Exception {
// Revoke a valid token
mockMvc.perform(post("/oauth2/revoke")
.header("Authorization", basicAuth("frontend-shell", "secret"))
.param("token", "valid-token-value"))
.andExpect(status().isOk());
// Revoke an already-revoked token
mockMvc.perform(post("/oauth2/revoke")
.header("Authorization", basicAuth("frontend-shell", "secret"))
.param("token", "valid-token-value"))
.andExpect(status().isOk());
// Revoke a nonsense string
mockMvc.perform(post("/oauth2/revoke")
.header("Authorization", basicAuth("frontend-shell", "secret"))
.param("token", "not-a-real-token"))
.andExpect(status().isOk());
}
}
The first test proves the end-to-end revocation flow: a token that was accepted before revocation is rejected after. The second test proves RFC 7009 compliance: the revocation endpoint always returns 200, regardless of token validity. This prevents attackers from using the endpoint as an oracle to probe for valid token values.