Opaque Token Introspection in Spring Security: Configuration and Performance
Opaque Token Introspection in Spring Security
The Assumption
Opaque tokens are the simple path: the resource server calls the authorization server’s introspection endpoint on every request, gets back the token’s active status and associated claims. The assumption: the introspection endpoint can handle the load.
At 10,000 requests per minute across five resource servers, the introspection endpoint receives 50,000 calls per minute. Without caching, this is a self-inflicted denial of service on your authorization server.
The second assumption: the network between the resource server and the introspection endpoint is secure. If an attacker can intercept the introspection request, they see the opaque token in transit and can replay it. If they can forge an introspection response, they can make the resource server accept any token as valid.
The Attack
Introspection endpoint as a bottleneck. Under load, the authorization server’s introspection endpoint becomes the single point of failure. A DDoS against the introspection endpoint does not just take down authentication. It takes down every API endpoint behind every resource server that uses opaque tokens. The blast radius of an introspection outage is the entire system.
Man-in-the-middle on the introspection call. The resource server sends the opaque token to the introspection endpoint over HTTP. An attacker on the network intercepts the request. They now have the token value and can use it directly. They can also forge the introspection response, returning {"active": true, "sub": "admin", "tenant_role": "SUPERADMIN"}. The resource server trusts the response and grants administrative access.
The Spec or Mechanism
RFC 7662 Section 2.1 defines the introspection request:
POST /oauth2/introspect HTTP/1.1
Host: auth.saas.example
Content-Type: application/x-www-form-urlencoded
Authorization: Basic Y29yZS1hcGk6aW50cm9zcGVjdGlvbi1zZWNyZXQ=
token=dGhpcyBpcyBhIG9wYXF1ZSB0b2tlbg
The response when the token is active:
{
"active": true,
"sub": "user-456",
"client_id": "frontend-shell",
"scope": "openid tenant:read tenant:write",
"tenant_id": "acme-corp",
"tenant_role": "ADMIN",
"exp": 1700000900,
"iat": 1700000000,
"iss": "https://auth.saas.example"
}
The response when the token is revoked, expired, or unknown:
{
"active": false
}
The active field is the only required field. All other fields are optional. A resource server that relies on custom claims from introspection must handle the case where those claims are absent.
The Implementation
Basic Opaque Token Configuration
// VULNERABLE: Introspection over plain HTTP, no caching
@Bean
public SecurityFilterChain resourceServerFilterChain(HttpSecurity http) throws Exception {
return http
.oauth2ResourceServer(oauth2 -> oauth2
.opaqueToken(opaque -> opaque
.introspectionUri("http://auth-server:8080/oauth2/introspect") // HTTP!
.introspectionClientCredentials("core-api", "introspection-secret")
)
)
.build();
}
// HARDENED: Introspection over TLS with certificate pinning
@Bean
public SecurityFilterChain resourceServerFilterChain(HttpSecurity http) throws Exception {
return http
.oauth2ResourceServer(oauth2 -> oauth2
.opaqueToken(opaque -> opaque
.introspector(cachingIntrospector())
)
)
.build();
}
@Bean
public OpaqueTokenIntrospector cachingIntrospector() {
return new CachingOpaqueTokenIntrospector(
"https://auth.saas.example/oauth2/introspect", // HTTPS
"core-api",
"introspection-secret",
redisTemplate()
);
}
Custom OpaqueTokenIntrospector with Redis Caching
@Component
public class CachingOpaqueTokenIntrospector implements OpaqueTokenIntrospector {
private final OpaqueTokenIntrospector delegate;
private final RedisTemplate<String, OAuth2AuthenticatedPrincipal> cache;
private final Duration cacheTtl;
public CachingOpaqueTokenIntrospector(
String introspectionUri,
String clientId,
String clientSecret,
RedisTemplate<String, OAuth2AuthenticatedPrincipal> cache) {
this.delegate = new NimbusOpaqueTokenIntrospector(
introspectionUri, clientId, clientSecret);
this.cache = cache;
this.cacheTtl = Duration.ofSeconds(5); // Revocation propagation delay
}
@Override
public OAuth2AuthenticatedPrincipal introspect(String token) {
// Hash the token for cache key (never store raw tokens in cache keys)
String cacheKey = "introspection:" + sha256(token);
OAuth2AuthenticatedPrincipal cached = cache.opsForValue().get(cacheKey);
if (cached != null) {
return cached;
}
// Cache miss: call the introspection endpoint
OAuth2AuthenticatedPrincipal principal = delegate.introspect(token);
// Cache the result with short TTL
cache.opsForValue().set(cacheKey, principal, cacheTtl);
return principal;
}
private String sha256(String input) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
return Base64.getUrlEncoder().withoutPadding().encodeToString(hash);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 not available", e);
}
}
}
The cache key uses a SHA-256 hash of the token rather than the token itself. This prevents the token value from appearing in Redis key listings, log files, or monitoring dashboards. The 5-second TTL means a revoked token is accepted for at most 5 seconds after revocation.
Spring Authorization Server: Issuing Opaque Tokens
// Configure the authorization server to issue opaque tokens for specific clients
@Bean
public RegisteredClientRepository registeredClientRepository(
PasswordEncoder passwordEncoder) {
RegisteredClient frontendShell = RegisteredClient
.withId(UUID.randomUUID().toString())
.clientId("frontend-shell")
.clientSecret(passwordEncoder.encode("frontend-secret"))
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.redirectUri("https://app.saas.example/callback")
.scope(OidcScopes.OPENID)
.scope("tenant:read")
.scope("tenant:write")
.tokenSettings(TokenSettings.builder()
.accessTokenFormat(OAuth2TokenFormat.REFERENCE)
.accessTokenTimeToLive(Duration.ofMinutes(15))
.refreshTokenTimeToLive(Duration.ofHours(24))
.reuseRefreshTokens(false)
.build())
.build();
return new InMemoryRegisteredClientRepository(frontendShell);
}
The OAuth2TokenFormat.REFERENCE setting makes Spring Authorization Server generate a random 128-bit token value instead of a JWT. The token metadata (subject, scopes, expiration, custom claims) is stored server-side in the OAuth2AuthorizationService. When the introspection endpoint receives the token, it looks up the stored metadata and returns it.
Load Testing the Introspection Endpoint
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class IntrospectionLoadTest {
@LocalServerPort
private int port;
@Test
void introspectionEndpointHandles1000ConcurrentRequests() throws Exception {
String token = obtainOpaqueToken();
int concurrency = 1000;
ExecutorService executor = Executors.newFixedThreadPool(50);
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch completeLatch = new CountDownLatch(concurrency);
AtomicInteger successes = new AtomicInteger(0);
AtomicInteger failures = new AtomicInteger(0);
List<Long> latencies = Collections.synchronizedList(new ArrayList<>());
for (int i = 0; i < concurrency; i++) {
executor.submit(() -> {
try {
startLatch.await(); // All threads start simultaneously
long start = System.nanoTime();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(HttpRequest.newBuilder()
.uri(URI.create("http://localhost:" + port + "/oauth2/introspect"))
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Authorization", basicAuth("core-api", "secret"))
.POST(HttpRequest.BodyPublishers.ofString("token=" + token))
.build(),
HttpResponse.BodyHandlers.ofString());
long elapsed = (System.nanoTime() - start) / 1_000_000;
latencies.add(elapsed);
if (response.statusCode() == 200) {
successes.incrementAndGet();
} else {
failures.incrementAndGet();
}
} catch (Exception e) {
failures.incrementAndGet();
} finally {
completeLatch.countDown();
}
});
}
startLatch.countDown(); // Release all threads
completeLatch.await(30, TimeUnit.SECONDS);
Collections.sort(latencies);
long p50 = latencies.get(latencies.size() / 2);
long p99 = latencies.get((int) (latencies.size() * 0.99));
assertThat(successes.get()).isGreaterThan(990); // >99% success rate
assertThat(p99).isLessThan(200); // P99 under 200ms
assertThat(failures.get()).isLessThan(10); // <1% failure rate
System.out.printf("P50: %dms, P99: %dms, Success: %d, Failures: %d%n",
p50, p99, successes.get(), failures.get());
}
}
The Verification
@SpringBootTest
@AutoConfigureMockMvc
class OpaqueTokenIntrospectionTest {
@Autowired
private MockMvc mockMvc;
@Test
void opaqueTokenIsValidatedViaIntrospection() throws Exception {
// Obtain an opaque token
MvcResult tokenResult = mockMvc.perform(post("/oauth2/token")
.header("Authorization", basicAuth("frontend-shell", "secret"))
.param("grant_type", "authorization_code")
.param("code", "valid-code")
.param("redirect_uri", "https://app.saas.example/callback"))
.andExpect(status().isOk())
.andReturn();
String accessToken = JsonPath.read(
tokenResult.getResponse().getContentAsString(), "$.access_token");
// Token should not look like a JWT (no dots)
assertThat(accessToken.split("\\.")).hasSize(1);
// Use the opaque token to access a protected resource
mockMvc.perform(get("/api/projects")
.header("Authorization", "Bearer " + accessToken))
.andExpect(status().isOk());
}
@Test
void revokedOpaqueTokenIsRejectedImmediately() throws Exception {
String accessToken = obtainOpaqueToken();
// Verify token works
mockMvc.perform(get("/api/projects")
.header("Authorization", "Bearer " + accessToken))
.andExpect(status().isOk());
// Revoke the token
mockMvc.perform(post("/oauth2/revoke")
.header("Authorization", basicAuth("frontend-shell", "secret"))
.param("token", accessToken))
.andExpect(status().isOk());
// Token should be rejected immediately (no cache delay for this test)
mockMvc.perform(get("/api/projects")
.header("Authorization", "Bearer " + accessToken))
.andExpect(status().isUnauthorized());
}
@Test
void introspectionRequiresTLS() {
// Verify that configuring HTTP (not HTTPS) for introspection fails at startup
assertThatThrownBy(() -> {
new NimbusOpaqueTokenIntrospector(
"http://auth-server/oauth2/introspect", // HTTP - insecure
"client-id", "client-secret");
}).isInstanceOf(IllegalArgumentException.class);
// Note: Spring does not enforce this by default.
// This test documents the expected behavior of a hardened configuration.
}
}
The second test demonstrates the key advantage of opaque tokens: revocation takes effect immediately. There is no 5-minute TTL window. The introspection endpoint checks the current state of the token, and once it is revoked, it returns {"active": false} on the next request. This is the primary reason to choose opaque tokens for browser-facing APIs where user logout and account compromise response must be immediate.