Skip to main content
the auth layer

Custom Claims, Token Introspection, and the JWKS Endpoint in Spring Authorization Server

6 min read Chapter 12 of 45

Custom Claims, Token Introspection, and the JWKS Endpoint

The Assumption

Most Spring Authorization Server setups issue JWTs with default claims: sub, iss, exp, iat, scope. The assumption: these standard claims are sufficient for authorization decisions.

In a multi-tenant system, a token without tenant context is a confused deputy waiting to happen. The resource server validates the signature, reads the subject, and serves data from whichever tenant the subject happens to query. Tenant isolation depends entirely on application-level checks that may or may not exist.

Custom claims embedded in the token allow the resource server to enforce tenant isolation at the token validation layer, before any application code runs. A token for user Alice in tenant “acme-corp” with "tenant_id": "acme-corp" in its claims can be rejected the moment it attempts to access a resource belonging to “globex-inc”, without needing a database lookup to determine Alice’s tenant membership.

The Attack

Confused deputy via missing tenant claim. The SaaS platform’s Core API serves multiple tenants. User Alice belongs to tenant “acme-corp.” Her JWT contains "sub": "alice" but no tenant claim. Alice sends a request to GET /api/tenants/globex-inc/projects. The resource server validates the JWT signature (valid), checks the subject (alice exists), and proceeds to the controller. The controller queries the database for projects belonging to “globex-inc.” Alice sees another tenant’s data.

This is not a hypothetical scenario. It is the default behavior of a Spring Security resource server that validates JWTs without custom claim enforcement. The @PreAuthorize annotation can check tenant membership, but it requires a database lookup on every request and depends on developers remembering to add the annotation to every endpoint.

With tenant_id in the token: the resource server extracts the claim during token validation, sets it in the SecurityContext, and a global filter rejects any request where the path’s tenant identifier does not match the token’s tenant_id. No developer can forget the check because it happens before the request reaches any controller.

The Spec or Mechanism

Spring Authorization Server provides OAuth2TokenCustomizer<JwtEncodingContext> for modifying tokens before signing. The customizer has access to the authorization context: the registered client, the authorization grant, the user’s authentication, and the existing claims. You can add, remove, or modify claims.

The JwtEncodingContext includes a getTokenType() method that distinguishes between access tokens and ID tokens. Different claims belong on different tokens:

  • Access token claims: tenant_id, roles, permissions, aud (resource server identifier). Consumed by resource servers for authorization decisions.
  • ID token claims: name, email, picture, auth_time, acr. Consumed by the client for user identity display.

The token introspection endpoint (RFC 7662) provides an alternative to local JWT validation. A resource server sends the token to the authorization server’s introspection endpoint and receives a JSON response indicating whether the token is active and what claims it carries. This is necessary for opaque tokens (which cannot be validated locally) and useful as a fallback for JWTs when you need to check revocation status.

The JWKS endpoint (/oauth2/jwks) publishes the public keys used for JWT signature verification. Resource servers fetch this endpoint to obtain the keys they need for local validation. The endpoint supports multiple keys (identified by kid) for rotation scenarios.

The Implementation

Token Customizer: Adding Custom Claims

@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> tokenCustomizer(
        UserTenantService userTenantService) {

    return context -> {
        if (context.getTokenType().equals(OAuth2TokenType.ACCESS_TOKEN)) {
            Authentication principal = context.getPrincipal();
            String username = principal.getName();

            // Resolve tenant from user's principal
            TenantMembership membership = userTenantService
                .getActiveMembership(username);

            context.getClaims().claims(claims -> {
                claims.put("tenant_id", membership.tenantId());
                claims.put("tenant_role", membership.role().name());
                claims.put("permissions", membership.permissions());
            });

            // Set audience to the resource server identifier
            context.getClaims().audience(List.of("core-api"));
        }

        if (context.getTokenType().getValue().equals("id_token")) {
            Authentication principal = context.getPrincipal();
            if (principal.getPrincipal() instanceof OidcUser oidcUser) {
                context.getClaims().claims(claims -> {
                    claims.put("name", oidcUser.getFullName());
                    claims.put("email", oidcUser.getEmail());
                });
            }
        }
    };
}

Resource Server: Custom JWT Authentication Converter

@Bean
public JwtAuthenticationConverter tenantAwareJwtConverter() {
    JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(jwt -> {
        Collection<GrantedAuthority> authorities = new ArrayList<>();

        // Extract scope-based authorities
        List<String> scopes = jwt.getClaimAsStringList("scope");
        if (scopes != null) {
            scopes.stream()
                .map(scope -> new SimpleGrantedAuthority("SCOPE_" + scope))
                .forEach(authorities::add);
        }

        // Extract role-based authority from tenant_role claim
        String tenantRole = jwt.getClaimAsString("tenant_role");
        if (tenantRole != null) {
            authorities.add(new SimpleGrantedAuthority("ROLE_" + tenantRole));
        }

        // Extract fine-grained permissions
        List<String> permissions = jwt.getClaimAsStringList("permissions");
        if (permissions != null) {
            permissions.stream()
                .map(SimpleGrantedAuthority::new)
                .forEach(authorities::add);
        }

        return authorities;
    });

    converter.setPrincipalClaimName("sub");
    return converter;
}

Token Introspection Endpoint Configuration

// Authorization Server: introspection endpoint is enabled by default
// but requires client authentication for access
@Bean
public RegisteredClientRepository registeredClientRepository(
        PasswordEncoder passwordEncoder) {

    // Resource server client for introspection
    RegisteredClient introspectionClient = RegisteredClient
        .withId(UUID.randomUUID().toString())
        .clientId("core-api-introspector")
        .clientSecret(passwordEncoder.encode("introspection-secret"))
        .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
        .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
        .build();

    // ... other clients ...
    return new InMemoryRegisteredClientRepository(introspectionClient);
}
// Resource Server: opaque token introspection configuration
@Bean
public SecurityFilterChain partnerApiFilterChain(HttpSecurity http) throws Exception {
    return http
        .securityMatcher("/partner/**")
        .oauth2ResourceServer(oauth2 -> oauth2
            .opaqueToken(opaque -> opaque
                .introspectionUri("https://auth.saas.example/oauth2/introspect")
                .introspectionClientCredentials(
                    "core-api-introspector", "introspection-secret")
            )
        )
        .build();
}

JWKS Endpoint: Multiple Keys for Rotation

@Bean
public JWKSource<SecurityContext> jwkSource(KeyStore keyStore) {
    // Support multiple active keys for rotation
    RSAKey currentKey = loadRSAKey(keyStore, "current-key");
    RSAKey previousKey = loadRSAKey(keyStore, "previous-key");

    JWKSet jwkSet = new JWKSet(List.of(currentKey, previousKey));

    return (jwkSelector, context) -> jwkSelector.select(jwkSet);
}

private RSAKey loadRSAKey(KeyStore keyStore, String alias) {
    try {
        RSAPublicKey publicKey = (RSAPublicKey) keyStore.getCertificate(alias).getPublicKey();
        RSAPrivateKey privateKey = (RSAPrivateKey) keyStore.getKey(alias,
            "keystore-password".toCharArray());

        return new RSAKey.Builder(publicKey)
            .privateKey(privateKey)
            .keyID(alias)
            .keyUse(KeyUse.SIGNATURE)
            .algorithm(JWSAlgorithm.RS256)
            .build();
    } catch (Exception e) {
        throw new IllegalStateException("Failed to load key: " + alias, e);
    }
}

The JWKS endpoint response for this configuration:

{
  "keys": [
    {
      "kty": "RSA",
      "kid": "current-key",
      "use": "sig",
      "alg": "RS256",
      "n": "<modulus>",
      "e": "AQAB"
    },
    {
      "kty": "RSA",
      "kid": "previous-key",
      "use": "sig",
      "alg": "RS256",
      "n": "<modulus>",
      "e": "AQAB"
    }
  ]
}

Resource servers select the correct key by matching the kid in the JWT header against the kid in the JWKS response. During key rotation, tokens signed with either key validate correctly.

The Verification

@SpringBootTest
@AutoConfigureMockMvc
class CustomClaimsTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void accessTokenContainsTenantClaims() throws Exception {
        // Use Spring Security test support to create a JWT with custom claims
        Jwt jwt = Jwt.withTokenValue("test-token")
            .header("alg", "RS256")
            .claim("sub", "alice")
            .claim("tenant_id", "acme-corp")
            .claim("tenant_role", "ADMIN")
            .claim("permissions", List.of("project:read", "project:write"))
            .claim("aud", List.of("core-api"))
            .claim("iss", "https://auth.saas.example")
            .issuedAt(Instant.now())
            .expiresAt(Instant.now().plusSeconds(300))
            .build();

        mockMvc.perform(get("/api/projects")
                .with(jwt().jwt(jwt)))
            .andExpect(status().isOk());
    }

    @Test
    void tokenWithoutTenantClaimIsRejected() throws Exception {
        Jwt jwt = Jwt.withTokenValue("test-token")
            .header("alg", "RS256")
            .claim("sub", "alice")
            // No tenant_id claim
            .claim("aud", List.of("core-api"))
            .claim("iss", "https://auth.saas.example")
            .issuedAt(Instant.now())
            .expiresAt(Instant.now().plusSeconds(300))
            .build();

        mockMvc.perform(get("/api/projects")
                .with(jwt().jwt(jwt)))
            .andExpect(status().isUnauthorized());
    }

    @Test
    void tokenWithWrongAudienceIsRejected() throws Exception {
        Jwt jwt = Jwt.withTokenValue("test-token")
            .header("alg", "RS256")
            .claim("sub", "alice")
            .claim("tenant_id", "acme-corp")
            .claim("aud", List.of("different-service")) // Wrong audience
            .claim("iss", "https://auth.saas.example")
            .issuedAt(Instant.now())
            .expiresAt(Instant.now().plusSeconds(300))
            .build();

        mockMvc.perform(get("/api/projects")
                .with(jwt().jwt(jwt)))
            .andExpect(status().isUnauthorized());
    }

    @Test
    void tokenGrantsCorrectAuthorities() throws Exception {
        Jwt jwt = Jwt.withTokenValue("test-token")
            .header("alg", "RS256")
            .claim("sub", "alice")
            .claim("tenant_id", "acme-corp")
            .claim("tenant_role", "ADMIN")
            .claim("permissions", List.of("project:read", "project:write"))
            .claim("scope", List.of("tenant:read", "tenant:write"))
            .claim("aud", List.of("core-api"))
            .claim("iss", "https://auth.saas.example")
            .issuedAt(Instant.now())
            .expiresAt(Instant.now().plusSeconds(300))
            .build();

        mockMvc.perform(get("/api/admin/settings")
                .with(jwt().jwt(jwt)))
            .andExpect(status().isOk());
        // This endpoint requires ROLE_ADMIN, extracted from tenant_role claim
    }
}

These tests verify that custom claims are correctly extracted and enforced. The second test is the most important: it proves that a token without tenant_id is rejected at the validation layer, not at the application layer. The enforcement is structural, not dependent on developer discipline.