Wiring a resource server in Spring Boot

JWT Authentication Course · lesson 12 of 13 · 5 min read

From dependency to a working authority check.

Open this lesson in the learning hub

Key points

  • Add spring-boot-starter-oauth2-resource-server and set issuer-uri. Boot discovers the JWKS endpoint and configures validation of signature, issuer and expiry.
  • Audience is not validated by default. It needs a custom validator, and it is the check most often missing in a working setup.
  • Scopes arrive as a space-separated scope claim and Spring maps them to authorities prefixed with SCOPE_. That prefix is why hasRole silently fails on them.
  • Roles from your own identity provider usually live in a custom claim, so you need a converter to turn them into ROLE_ authorities.
  • Set the session policy to stateless. Otherwise Spring creates an HttpSession per request, which quietly defeats the point of using tokens.
  • Test with a real token from the provider once, then use @WithMockJwt or the JWT test helpers for the rest - integration tests that mint real tokens are slow and flaky.

Example

// build.gradle
//   implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
class ResourceServerConfig {

    @Bean
    SecurityFilterChain api(HttpSecurity http) throws Exception {
        return http
                .securityMatcher("/api/**")
                .csrf(csrf -> csrf.disable())        // no cookies, no CSRF surface
                .sessionManagement(s -> s.sessionCreationPolicy(
                        SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(a -> a
                        .requestMatchers("/api/public/**").permitAll()
                        .anyRequest().authenticated())
                .oauth2ResourceServer(o -> o.jwt(j ->
                        j.jwtAuthenticationConverter(converter())))
                .build();
    }

    // Map a custom "roles" claim onto ROLE_ authorities.
    static JwtAuthenticationConverter converter() {
        JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter();
        // scope -> SCOPE_read, SCOPE_write  (the default)
        JwtAuthenticationConverter c = new JwtAuthenticationConverter();
        c.setJwtGrantedAuthoritiesConverter(jwt -> {
            var authorities = new ArrayList<GrantedAuthority>(scopes.convert(jwt));
            List<String> roles = jwt.getClaimAsStringList("roles");
            if (roles != null) {
                roles.forEach(r -> authorities.add(
                        new SimpleGrantedAuthority("ROLE_" + r)));
            }
            return authorities;
        });
        return c;
    }
}

@RestController
class OrderController {

    // SCOPE_ prefix for scopes, ROLE_ for roles. Mixing them up is the
    // most common cause of an inexplicable 403.
    @GetMapping("/api/orders")
    @PreAuthorize("hasAuthority('SCOPE_orders.read')")
    List<Order> list(@AuthenticationPrincipal Jwt jwt) {
        return service.forUser(jwt.getSubject());    // sub, not a request param
    }
}

issuer-uri gets you signature and expiry checks; audience, role mapping and STATELESS are the three you must add yourself.

This is a reading copy. The full lesson — with the visual explainer, the interactive lab and a Run button for the code — lives in the JWT Authentication Course course, and every lesson in it is listed on the JWT Authentication Course contents page.