Skip to main content
the auth layer

Revocation Endpoint and Token State Management in Spring Authorization Server

7 min read Chapter 15 of 45

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:

  1. The client sends a POST to the revocation endpoint with the token and an optional token_type_hint.
  2. The authorization server validates client authentication (the same client that obtained the token, or a privileged client).
  3. The server invalidates the token. If a refresh token is revoked, the associated access token SHOULD also be revoked.
  4. 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.