How to configure a Spring Boot service as an OAuth2 resource server that validates JWTs from an identity provider — covering JWKS validation, custom claims extraction, and method-level security.
An OAuth2 resource server accepts JWTs issued by an authorisation server — Cognito, Keycloak, Auth0, or any OIDC-compliant provider — and validates them on every request. Spring Security’s resource server support handles JWT signature verification, expiry checking, and claim extraction with minimal configuration. Understanding what it does by default, and where to add custom claim logic, covers most production scenarios.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
The most common configuration: validate JWTs against the issuer’s public keys fetched from its JWKS endpoint.
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://cognito-idp.eu-west-2.amazonaws.com/eu-west-2_ABC123
Spring Security fetches {issuer-uri}/.well-known/openid-configuration to discover the JWKS endpoint, downloads the public keys, and verifies every incoming JWT’s signature. Keys are cached and refreshed automatically when key rotation occurs (new kid in the JWT header).
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health/**").permitAll()
.requestMatchers("/actuator/**").hasAuthority("SCOPE_internal")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.build();
}
}
SCOPE_internal maps to the internal scope in the JWT scp claim. Spring Security automatically maps scopes to SCOPE_* granted authorities.
JWTs from your identity provider often carry custom claims — user IDs, roles, groups, tenants. Extract them by customising the JwtAuthenticationConverter:
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter rolesConverter = new JwtGrantedAuthoritiesConverter();
rolesConverter.setAuthoritiesClaimName("cognito:groups");
rolesConverter.setAuthorityPrefix("ROLE_");
JwtGrantedAuthoritiesConverter scopesConverter = new JwtGrantedAuthoritiesConverter();
scopesConverter.setAuthorityPrefix("SCOPE_");
DelegatingJwtGrantedAuthoritiesConverter combined =
new DelegatingJwtGrantedAuthoritiesConverter(rolesConverter, scopesConverter);
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(combined);
return converter;
}
This extracts both Cognito groups (as ROLE_*) and standard scopes (as SCOPE_*).
@RestController
@RequestMapping("/orders")
public class OrderController {
@GetMapping
public List<OrderDto> listOrders(
@AuthenticationPrincipal Jwt jwt
) {
String userId = jwt.getSubject();
String tenant = jwt.getClaimAsString("custom:tenant");
List<String> groups = jwt.getClaimAsStringList("cognito:groups");
return orderService.findForUser(userId, tenant);
}
}
@AuthenticationPrincipal Jwt injects the parsed JWT directly. No custom principal class required for simple cases.
For services that use the calling user’s identity extensively, map the JWT to a domain principal:
public record TradingPrincipal(String userId, String tenant, Set<String> roles) {
public static TradingPrincipal from(Jwt jwt) {
List<String> groups = jwt.getClaimAsStringList("cognito:groups");
return new TradingPrincipal(
jwt.getSubject(),
jwt.getClaimAsString("custom:tenant"),
groups != null ? new HashSet<>(groups) : Set.of()
);
}
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setPrincipalClaimName("sub");
// return a custom Authentication that holds TradingPrincipal
// via a custom JwtAuthenticationToken subclass if needed
return converter;
}
Or use a @ControllerAdvice that extracts the principal once and makes it available via a method argument:
@ControllerAdvice
public class PrincipalResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
return TradingPrincipal.class.equals(parameter.getParameterType());
}
@Override
public Object resolveArgument(MethodParameter parameter, ...) {
Jwt jwt = (Jwt) SecurityContextHolder.getContext()
.getAuthentication().getPrincipal();
return TradingPrincipal.from(jwt);
}
}
With @EnableMethodSecurity, secure individual methods:
@GetMapping("/{marketId}/orders")
@PreAuthorize("hasRole('TRADER') or hasAuthority('SCOPE_admin')")
public List<OrderDto> getMarketOrders(@PathVariable String marketId) {
return orderService.findByMarket(marketId);
}
Use @WithMockUser for most tests. For JWT-specific claim testing:
@Test
@WithMockJwtToken(subject = "user123", claims = {"custom:tenant=acme", "cognito:groups=[TRADER]"})
void getOrders_returnsOnlyUserOrders() throws Exception {
// ...
}
Or construct the JWT manually in the test:
Jwt jwt = Jwt.withTokenValue("token")
.header("alg", "RS256")
.claim("sub", "user123")
.claim("custom:tenant", "acme")
.claim("cognito:groups", List.of("TRADER"))
.expiresAt(Instant.now().plusSeconds(300))
.issuedAt(Instant.now())
.build();
mvc.perform(get("/orders")
.with(jwt().jwt(jwt)))
.andExpect(status().isOk());
With JWT validation, custom claim extraction, and method security in place, your resource server correctly enforces authentication and authorisation without writing a token verification line yourself.
If you’re configuring Spring Security for a production API and want a review, get in touch.