Hire Me
← All Writing Spring Boot

OAuth2 Resource Server — Validating JWTs with Spring Security

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.

Dependencies

<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>

JWKS-based validation

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).

Security configuration

@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.

Custom claim extraction

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_*).

Accessing claims in a controller

@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.

Custom principal for richer context

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);
    }
}

Method-level security

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);
}

Testing with JWT

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.

Samuel Jackson

Samuel Jackson

Senior Java Back End Developer & Contractor

Senior Java Back End Developer — Betfair Exchange API specialist, Spring Boot, AWS, and event-driven architecture. 20+ years delivering high-performance systems across betting, finance, energy, retail, and government. Available for Java contracting.