Skip to main content
the auth layer

The Hybrid Token Architecture: Opaque for Browsers, JWTs for Services

8 min read Chapter 18 of 45

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

Hybrid token exchange architecture: browser sends opaque token to API Gateway, which exchanges it for scoped JWTs with 1-minute TTL for each internal service

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.