Opaque Tokens vs JWTs: The Trade-off That Depends on Your Threat Model
Opaque Tokens vs JWTs
The token format decision is not a technical preference. It is a security architecture decision that determines which attacks are possible and which mitigations are available at each boundary in your system.
A JWT is a self-contained credential. It carries its own proof of validity (the signature) and its own authorization data (the claims). Any party with the public key can validate it. No network call required. The cost: the token cannot be revoked in real time, its contents are visible to anyone who intercepts it, and its size grows with the number of claims.
An opaque token is a reference. It is a random string that means nothing without a lookup. Validation requires a network call to the authorization server’s introspection endpoint. The cost: every request adds latency (the introspection round-trip), the authorization server becomes a single point of failure for all protected resources, and the system cannot function if the introspection endpoint is unreachable.
Neither format is universally better. The correct choice depends on the boundary the token crosses and the threat model at that boundary.
Decision Rule
| Boundary | Token format | Rationale |
|---|---|---|
| Browser → API Gateway | Opaque | Instant revocation on logout/compromise. Token contents invisible to client-side JavaScript. |
| Mobile App → API Gateway | Opaque | Same as browser: revocation priority over performance. |
| API Gateway → Internal Service | JWT | Stateless validation. No introspection bottleneck. Short TTL (1 min) limits exposure. |
| Service → Service (same trust zone) | JWT | Low latency. Services trust each other. Revocation delay acceptable. |
| Third-party → API | Opaque | You need full control over token lifecycle. Revocation must be immediate. |
| Partner API (external) | JWT with introspection fallback | Partners validate locally for performance but call introspection for sensitive operations. |
The decision factors:
-
How quickly must revocation take effect? If the answer is “immediately” (user logout, compromised account, compliance requirement), use opaque tokens. If “within 5 minutes” is acceptable, JWTs with short TTL work.
-
How sensitive is the token content? JWT payloads are base64-encoded, not encrypted. Anyone with the token can read the claims. If claims contain PII (email, tenant_id, roles), opaque tokens prevent information leakage to intermediaries.
-
What is the latency budget? Introspection adds 5-50ms per request (depending on network topology and auth server load). If the latency budget is <10ms for token validation, JWTs are the only option.
-
What is the authorization server’s availability SLA? With opaque tokens, if the introspection endpoint is down, all protected resources are unreachable. With JWTs, resource servers continue validating tokens independently.
Spring Authorization Server: Token Format Configuration
// Opaque token format for browser-facing client
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")
.tokenSettings(TokenSettings.builder()
.accessTokenFormat(OAuth2TokenFormat.REFERENCE) // Opaque
.accessTokenTimeToLive(Duration.ofMinutes(15))
.refreshTokenTimeToLive(Duration.ofHours(24))
.reuseRefreshTokens(false)
.build())
.build();
// JWT format for service-to-service client
RegisteredClient paymentService = RegisteredClient
.withId(UUID.randomUUID().toString())
.clientId("payment-service")
.clientSecret(passwordEncoder.encode("payment-secret"))
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.tokenSettings(TokenSettings.builder()
.accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED) // JWT
.accessTokenTimeToLive(Duration.ofMinutes(1))
.build())
.build();
The OAuth2TokenFormat.REFERENCE setting tells Spring Authorization Server to issue an opaque token (a random string stored server-side) instead of a JWT. The OAuth2TokenFormat.SELF_CONTAINED setting issues a signed JWT. Both formats use the same grant flows, the same scopes, and the same authorization logic. The difference is purely in how the token is represented and validated.
Information Leakage Comparison
A JWT in a browser’s local storage or a network capture reveals:
{
"sub": "user-456",
"tenant_id": "acme-corp",
"tenant_role": "ADMIN",
"permissions": ["billing:read", "billing:write", "users:manage"],
"email": "[email protected]",
"iss": "https://auth.saas.example",
"aud": ["core-api"],
"exp": 1700000000
}
An attacker who intercepts this token learns: the user’s identity, their tenant, their role, their permissions, their email, and which services they can access. This information is useful for social engineering, targeted phishing, and planning privilege escalation attacks.
An opaque token in the same position reveals: dGhpcyBpcyBhIHJhbmRvbSBzdHJpbmc. Nothing useful. The attacker can attempt to use the token (which is why revocation matters), but they learn nothing about the system’s structure.
Performance at Scale
Measured on the multi-tenant platform under load:
| Metric | JWT validation | Opaque introspection | Opaque + cache (5s TTL) |
|---|---|---|---|
| P50 latency | 0.3ms | 12ms | 0.4ms (cache hit) / 12ms (miss) |
| P99 latency | 1.2ms | 45ms | 1.5ms / 45ms |
| Auth server load | 0 RPM | 50,000 RPM | 10,000 RPM |
| Revocation propagation | 5 min (TTL) | Instant | 5 seconds (cache TTL) |
The cache column shows the practical middle ground: opaque tokens with a short-lived cache at the resource server. The 5-second cache TTL means revocation takes at most 5 seconds to propagate (vs 5 minutes for JWTs) while reducing introspection load by 80%.
This is not a universally correct number. If your compliance requirement is “revocation within 1 second,” the cache TTL must be ≤1 second, which provides less load reduction. If “revocation within 1 minute” is acceptable, a 60-second cache is more efficient.