The Hybrid Token Architecture: Opaque for Browsers, JWTs for Services
The Hybrid Token Architecture
The Assumption
Teams choose one token format for the entire system. The assumption: consistency in token format reduces complexity.
The opposite is true. A single token format across all boundaries means accepting the worst trade-offs of that format everywhere. JWTs everywhere means accepting revocation delay for browser sessions. Opaque tokens everywhere means accepting introspection load for service-to-service calls that do not need instant revocation.
The hybrid architecture assigns token formats based on the threat model at each boundary. The frontend shell receives opaque tokens (instant revocation, no information leakage). Internal services exchange opaque tokens for short-lived JWTs (stateless validation, no introspection bottleneck). The system gets the security properties it needs at each boundary without paying the costs where they are unnecessary.
The Attack
Lateral movement via stolen opaque token exchange. An attacker compromises the frontend shell’s HTTP-only cookie containing an opaque token. They present this token to the internal token exchange endpoint, receive a JWT scoped for internal service communication, and use that JWT to access internal services directly. The opaque token is revoked (user logs out), but the internal JWT remains valid for its 1-minute TTL.
The attack surface: the token exchange endpoint must validate that the requesting party is authorized to exchange tokens. If any bearer of an opaque token can exchange it for an internal JWT, the exchange endpoint becomes a privilege escalation vector.
Mitigation: the token exchange endpoint requires both the opaque token AND proof that the request originates from a trusted service (mTLS client certificate, or a service-specific client_credentials grant). A browser cannot directly call the exchange endpoint because it lacks the service identity.
The Spec or Mechanism
RFC 8693 defines the Token Exchange grant type. The key parameters:
POST /oauth2/token HTTP/1.1
Host: auth.saas.example
Content-Type: application/x-www-form-urlencoded
Authorization: Basic Y29yZS1hcGk6c2VydmljZS1zZWNyZXQ=
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=opaque-token-value
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
&requested_token_type=urn:ietf:params:oauth:token-type:jwt
&audience=payment-service
&scope=payment:process
The response contains a new token (JWT in this case) scoped to the requested audience and permissions:
{
"access_token": "eyJhbGciOiJSUzI1NiJ9...",
"token_type": "Bearer",
"expires_in": 60,
"scope": "payment:process",
"issued_token_type": "urn:ietf:params:oauth:token-type:jwt"
}
The semantics: “I (core-api) have an authenticated user. I need a token to call payment-service on behalf of this user. The new token should have limited scope (only payment:process) and a short lifetime (60 seconds).”
This is delegation with scope reduction. The exchanged token is never more powerful than the original. It is scoped to a specific audience and specific permissions. If the original opaque token had tenant:read tenant:write billing:read billing:write, the exchanged JWT for payment-service only carries payment:process.
The Implementation
Architecture Overview
The diagram illustrates the boundary between external and internal token formats. The browser never sees a JWT. The opaque token is introspected at the gateway, then exchanged for short-lived, audience-restricted JWTs scoped to each downstream service. A leaked internal JWT is limited to one service and expires in 60 seconds, while the opaque token at the perimeter can be revoked instantly via introspection.
Spring Authorization Server: Token Exchange Grant Type
@Configuration
public class TokenExchangeConfig {
@Bean
public SecurityFilterChain authorizationServerChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.tokenEndpoint(token -> token
.authenticationProvider(tokenExchangeAuthenticationProvider())
);
return http.build();
}
@Bean
public AuthenticationProvider tokenExchangeAuthenticationProvider() {
return new TokenExchangeAuthenticationProvider(
authorizationService(),
tokenGenerator(),
registeredClientRepository()
);
}
}
@Component
public class TokenExchangeAuthenticationProvider implements AuthenticationProvider {
private final OAuth2AuthorizationService authorizationService;
private final OAuth2TokenGenerator<?> tokenGenerator;
private final RegisteredClientRepository clientRepository;
@Override
public Authentication authenticate(Authentication authentication) {
OAuth2TokenExchangeAuthenticationToken exchangeToken =
(OAuth2TokenExchangeAuthenticationToken) authentication;
// 1. Validate the subject token (the opaque token being exchanged)
OAuth2Authorization subjectAuthorization = authorizationService
.findByToken(exchangeToken.getSubjectToken(), OAuth2TokenType.ACCESS_TOKEN);
if (subjectAuthorization == null || !isActive(subjectAuthorization)) {
throw new OAuth2AuthenticationException(
new OAuth2Error("invalid_grant", "Subject token is not active", null));
}
// 2. Validate the requesting client is authorized for token exchange
RegisteredClient requestingClient = clientRepository
.findByClientId(exchangeToken.getClientId());
if (!requestingClient.getAuthorizationGrantTypes()
.contains(new AuthorizationGrantType("urn:ietf:params:oauth:grant-type:token-exchange"))) {
throw new OAuth2AuthenticationException(
new OAuth2Error("unauthorized_client"));
}
// 3. Validate audience restriction
String requestedAudience = exchangeToken.getAudience();
if (!isAllowedAudience(requestingClient, requestedAudience)) {
throw new OAuth2AuthenticationException(
new OAuth2Error("invalid_target",
"Client not authorized for requested audience", null));
}
// 4. Scope reduction: exchanged token scope is intersection of
// requested scope and subject token's scope
Set<String> allowedScopes = intersect(
exchangeToken.getRequestedScopes(),
subjectAuthorization.getAuthorizedScopes());
// 5. Generate the exchanged token (JWT with reduced scope and short TTL)
OAuth2TokenContext tokenContext = DefaultOAuth2TokenContext.builder()
.registeredClient(requestingClient)
.principal(subjectAuthorization.getAttribute(Principal.class.getName()))
.tokenType(OAuth2TokenType.ACCESS_TOKEN)
.authorizedScopes(allowedScopes)
.put("audience", requestedAudience)
.put("subject_authorization", subjectAuthorization)
.build();
OAuth2Token exchangedToken = tokenGenerator.generate(tokenContext);
// 6. Build the response
return new OAuth2AccessTokenAuthenticationToken(
requestingClient,
exchangeToken,
(OAuth2AccessToken) exchangedToken);
}
@Override
public boolean supports(Class<?> authentication) {
return OAuth2TokenExchangeAuthenticationToken.class.isAssignableFrom(authentication);
}
}
Token Customizer: Exchanged JWT Claims
@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> tokenExchangeCustomizer() {
return context -> {
if (isTokenExchange(context)) {
OAuth2Authorization subjectAuth = context.get("subject_authorization");
String audience = context.get("audience");
context.getClaims().claims(claims -> {
// Propagate tenant context from the original token
claims.put("tenant_id", subjectAuth.getAttribute("tenant_id"));
claims.put("act", Map.of(
"sub", context.getRegisteredClient().getClientId()
));
// Set audience to the target service
claims.put("aud", List.of(audience));
});
// Short TTL for exchanged tokens
context.getClaims().expiresAt(Instant.now().plusSeconds(60));
}
};
}
The act claim (RFC 8693 Section 4.1) records the delegation chain: who is acting on behalf of whom. The JWT payload shows:
{
"sub": "user-456",
"tenant_id": "acme-corp",
"aud": ["payment-service"],
"scope": "payment:process",
"act": {
"sub": "core-api"
},
"exp": 1700000060,
"iat": 1700000000,
"iss": "https://auth.saas.example"
}
This token says: “user-456 from acme-corp is the subject. core-api is acting on their behalf. The token is only valid for payment-service. It expires in 60 seconds.”
Resource Server: Multiple SecurityFilterChain
@Configuration
@EnableWebSecurity
public class ResourceServerConfig {
// Order 1: Internal endpoints validated via JWT
@Bean
@Order(1)
public SecurityFilterChain internalServiceChain(HttpSecurity http) throws Exception {
return http
.securityMatcher("/internal/**")
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.decoder(jwtDecoder())
.jwtAuthenticationConverter(serviceJwtConverter())
)
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/internal/**").hasAuthority("SCOPE_service:call")
.anyRequest().denyAll()
)
.build();
}
// Order 2: External API endpoints validated via opaque token introspection
@Bean
@Order(2)
public SecurityFilterChain externalApiChain(HttpSecurity http) throws Exception {
return http
.securityMatcher("/api/**")
.oauth2ResourceServer(oauth2 -> oauth2
.opaqueToken(opaque -> opaque
.introspector(cachingIntrospector())
)
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/**").authenticated()
.anyRequest().denyAll()
)
.build();
}
@Bean
public JwtDecoder jwtDecoder() {
NimbusJwtDecoder decoder = NimbusJwtDecoder
.withJwkSetUri("https://auth.saas.example/oauth2/jwks")
.jwsAlgorithm(SignatureAlgorithm.RS256)
.build();
// Validate that the token is an exchanged token (has "act" claim)
OAuth2TokenValidator<Jwt> exchangeValidator = token -> {
if (token.getClaim("act") == null) {
return OAuth2TokenValidatorResult.failure(
new OAuth2Error("invalid_token",
"Internal tokens must be exchanged tokens with 'act' claim", null));
}
return OAuth2TokenValidatorResult.success();
};
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer("https://auth.saas.example"),
exchangeValidator));
return decoder;
}
}
The internalServiceChain requires JWTs with the act claim (proving they are exchanged tokens, not directly issued). This prevents a client from obtaining a JWT directly from the authorization server and using it to access internal endpoints. Only tokens that went through the exchange flow are accepted.
The Verification
@SpringBootTest
@AutoConfigureMockMvc
class HybridTokenArchitectureTest {
@Autowired
private MockMvc mockMvc;
@Test
void opaqueTokenWorksForExternalApi() throws Exception {
String opaqueToken = obtainOpaqueToken("frontend-shell");
mockMvc.perform(get("/api/projects")
.header("Authorization", "Bearer " + opaqueToken))
.andExpect(status().isOk());
}
@Test
void opaqueTokenRejectedForInternalEndpoint() throws Exception {
String opaqueToken = obtainOpaqueToken("frontend-shell");
// Opaque tokens cannot be used for internal endpoints (JWT required)
mockMvc.perform(get("/internal/payment/process")
.header("Authorization", "Bearer " + opaqueToken))
.andExpect(status().isUnauthorized());
}
@Test
void exchangedJwtWorksForInternalEndpoint() throws Exception {
String opaqueToken = obtainOpaqueToken("frontend-shell");
// Exchange opaque token for JWT scoped to payment-service
MvcResult exchangeResult = mockMvc.perform(post("/oauth2/token")
.header("Authorization", basicAuth("core-api", "service-secret"))
.param("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange")
.param("subject_token", opaqueToken)
.param("subject_token_type", "urn:ietf:params:oauth:token-type:access_token")
.param("audience", "payment-service")
.param("scope", "payment:process"))
.andExpect(status().isOk())
.andReturn();
String jwt = JsonPath.read(
exchangeResult.getResponse().getContentAsString(), "$.access_token");
// Use exchanged JWT for internal call
mockMvc.perform(post("/internal/payment/process")
.header("Authorization", "Bearer " + jwt)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"amount\": 99.99, \"currency\": \"USD\"}"))
.andExpect(status().isOk());
}
@Test
void revocationOfOpaqueTokenPreventsNewExchanges() throws Exception {
String opaqueToken = obtainOpaqueToken("frontend-shell");
// Exchange works before revocation
mockMvc.perform(post("/oauth2/token")
.header("Authorization", basicAuth("core-api", "service-secret"))
.param("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange")
.param("subject_token", opaqueToken)
.param("subject_token_type", "urn:ietf:params:oauth:token-type:access_token")
.param("audience", "payment-service"))
.andExpect(status().isOk());
// Revoke the opaque token
mockMvc.perform(post("/oauth2/revoke")
.header("Authorization", basicAuth("frontend-shell", "secret"))
.param("token", opaqueToken))
.andExpect(status().isOk());
// Exchange fails after revocation
mockMvc.perform(post("/oauth2/token")
.header("Authorization", basicAuth("core-api", "service-secret"))
.param("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange")
.param("subject_token", opaqueToken)
.param("subject_token_type", "urn:ietf:params:oauth:token-type:access_token")
.param("audience", "payment-service"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error").value("invalid_grant"));
}
@Test
void exchangedJwtHasReducedScopeAndShortTtl() throws Exception {
String opaqueToken = obtainOpaqueToken("frontend-shell");
// Original token has scopes: tenant:read, tenant:write, billing:read
MvcResult exchangeResult = mockMvc.perform(post("/oauth2/token")
.header("Authorization", basicAuth("core-api", "service-secret"))
.param("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange")
.param("subject_token", opaqueToken)
.param("subject_token_type", "urn:ietf:params:oauth:token-type:access_token")
.param("audience", "payment-service")
.param("scope", "payment:process")) // Requesting scope not in original
.andReturn();
// The exchanged token should only have scopes that intersect with the original
String jwt = JsonPath.read(
exchangeResult.getResponse().getContentAsString(), "$.access_token");
int expiresIn = JsonPath.read(
exchangeResult.getResponse().getContentAsString(), "$.expires_in");
// Short TTL (60 seconds for exchanged tokens)
assertThat(expiresIn).isLessThanOrEqualTo(60);
}
}
The fourth test is the most important: it proves that revoking the opaque token (user logout) immediately prevents new token exchanges. Previously exchanged JWTs remain valid for their remaining TTL (up to 60 seconds), but no new internal tokens can be minted. This is the hybrid architecture’s key property: instant revocation of the user-facing token propagates to internal services within the exchanged token’s TTL.