OAuth2 and JWT in Spring Boot — Resource Server Configuration, Token Validation, and Claims Extraction

by Eric Hanson, Backend Developer at Clean Systems Consulting

The resource server role

In OAuth2 terminology, three parties interact:

  • Authorization server (Keycloak, Auth0, Okta, custom) — issues tokens
  • Resource server (your Spring Boot service) — validates tokens and serves protected resources
  • Client (mobile app, SPA, another service) — presents tokens to the resource server

The resource server's job: receive a JWT Bearer token, validate it (signature, expiry, issuer), extract claims, and make authorization decisions based on those claims. Spring Security's resource server support handles the validation; you configure how claims map to authorities and how the application uses them.

Dependency and basic configuration

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.example.com/realms/myapp
          # Spring auto-discovers JWKS URI from issuer's .well-known/openid-configuration
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/health").permitAll()
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(Customizer.withDefaults())
            )
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .csrf(csrf -> csrf.disable())
            .build();
    }
}

With issuer-uri configured, Spring Security:

  1. Fetches https://auth.example.com/realms/myapp/.well-known/openid-configuration at startup
  2. Discovers the JWKS URI from the metadata
  3. Downloads the public keys for token signature verification
  4. Caches the keys and refreshes when a token references an unknown key ID (kid)

This is all automatic. The JWKS URI can also be configured explicitly if auto-discovery isn't available:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: https://auth.example.com/realms/myapp/protocol/openid-connect/certs

Claims extraction — mapping JWT claims to Spring authorities

By default, Spring Security maps the JWT scope claim to granted authorities with the SCOPE_ prefix. A token with scope: "orders:read orders:write" produces authorities SCOPE_orders:read and SCOPE_orders:write.

Many authorization servers use different claim structures — Keycloak uses realm_access.roles and resource_access.{clientId}.roles; custom servers may use roles, permissions, or any other claim name. Configure a custom converter:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http,
        JwtAuthenticationConverter jwtAuthConverter) throws Exception {
    return http
        .oauth2ResourceServer(oauth2 -> oauth2
            .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthConverter))
        )
        .build();
}

@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
    JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter =
        new JwtGrantedAuthoritiesConverter();
    grantedAuthoritiesConverter.setAuthoritiesClaimName("roles");  // claim name
    grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");        // prefix

    JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
    return converter;
}

For complex claim structures — Keycloak's nested roles, custom permission arrays, multiple claims merged:

@Component
public class KeycloakJwtConverter implements Converter<Jwt, AbstractAuthenticationToken> {

    private final String clientId;

    public KeycloakJwtConverter(@Value("${spring.security.oauth2.client-id}") String clientId) {
        this.clientId = clientId;
    }

    @Override
    public AbstractAuthenticationToken convert(Jwt jwt) {
        Collection<GrantedAuthority> authorities = Stream.concat(
            extractRealmRoles(jwt),
            extractClientRoles(jwt)
        ).collect(Collectors.toSet());

        return new JwtAuthenticationToken(jwt, authorities, extractUsername(jwt));
    }

    private Stream<GrantedAuthority> extractRealmRoles(Jwt jwt) {
        Map<String, Object> realmAccess = jwt.getClaim("realm_access");
        if (realmAccess == null) return Stream.empty();

        List<String> roles = (List<String>) realmAccess.getOrDefault("roles", List.of());
        return roles.stream().map(role -> new SimpleGrantedAuthority("ROLE_" + role));
    }

    private Stream<GrantedAuthority> extractClientRoles(Jwt jwt) {
        Map<String, Object> resourceAccess = jwt.getClaim("resource_access");
        if (resourceAccess == null) return Stream.empty();

        Map<String, Object> clientAccess = (Map<String, Object>)
            resourceAccess.getOrDefault(clientId, Map.of());
        List<String> roles = (List<String>) clientAccess.getOrDefault("roles", List.of());
        return roles.stream().map(role -> new SimpleGrantedAuthority("ROLE_" + role));
    }

    private String extractUsername(Jwt jwt) {
        return jwt.getClaimAsString("preferred_username");
    }
}

Register it:

.oauth2ResourceServer(oauth2 -> oauth2
    .jwt(jwt -> jwt.jwtAuthenticationConverter(keycloakJwtConverter))
)

Accessing JWT claims in controllers and services

The JWT principal is accessible via @AuthenticationPrincipal:

@GetMapping("/profile")
public ProfileResponse getProfile(@AuthenticationPrincipal Jwt jwt) {
    String userId    = jwt.getSubject();                        // sub claim
    String email     = jwt.getClaimAsString("email");
    List<String> roles = jwt.getClaimAsStringList("roles");
    Instant issuedAt  = jwt.getIssuedAt();
    Instant expiresAt = jwt.getExpiresAt();

    return profileService.findProfile(userId);
}

For services that need the current user's ID without the full JWT:

@GetMapping("/orders")
public List<OrderResponse> getMyOrders(@AuthenticationPrincipal Jwt jwt) {
    String userId = jwt.getSubject();
    return orderService.findOrdersForUser(userId);
}

If controllers use the typed Authentication instead:

@GetMapping("/orders")
public List<OrderResponse> getMyOrders(Authentication authentication) {
    JwtAuthenticationToken jwtAuth = (JwtAuthenticationToken) authentication;
    Jwt jwt = jwtAuth.getToken();
    String userId = jwt.getSubject();
    return orderService.findOrdersForUser(userId);
}

Scope-based authorization

OAuth2 scopes represent what the client is allowed to do. Protect endpoints by required scope:

@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {

    @GetMapping
    @PreAuthorize("hasAuthority('SCOPE_orders:read')")
    public List<OrderResponse> listOrders(@AuthenticationPrincipal Jwt jwt) {
        return orderService.findOrdersForUser(jwt.getSubject());
    }

    @PostMapping
    @PreAuthorize("hasAuthority('SCOPE_orders:write')")
    public ResponseEntity<OrderResponse> createOrder(@AuthenticationPrincipal Jwt jwt,
            @RequestBody CreateOrderRequest request) {
        Order order = orderService.createOrder(jwt.getSubject(), request);
        return ResponseEntity.created(URI.create("/api/v1/orders/" + order.getId()))
            .body(OrderResponse.from(order));
    }

    @DeleteMapping("/{id}")
    @PreAuthorize("hasAuthority('SCOPE_orders:delete') and @orderSecurity.isOwner(#id, authentication)")
    public ResponseEntity<Void> deleteOrder(@PathVariable Long id) {
        orderService.deleteOrder(id);
        return ResponseEntity.noContent().build();
    }
}

Combining scope checks with ownership checks ensures both the client has the right scope (granted by the authorization server) and the user has the right to access that specific resource.

Token validation customization

Spring Security validates: signature, expiry (exp claim), not-before (nbf claim), and issuer (iss claim). Add custom validation:

@Bean
public JwtDecoder jwtDecoder(OAuth2ResourceServerProperties properties) {
    NimbusJwtDecoder decoder = NimbusJwtDecoder
        .withJwkSetUri(properties.getJwt().getJwkSetUri())
        .build();

    // Add custom validators
    OAuth2TokenValidator<Jwt> audienceValidator = new JwtClaimValidator<>(
        JwtClaimNames.AUD,
        aud -> aud != null && ((List<?>) aud).contains("order-service")
    );

    OAuth2TokenValidator<Jwt> defaultValidators =
        JwtValidators.createDefaultWithIssuer(properties.getJwt().getIssuerUri());

    OAuth2TokenValidator<Jwt> combined =
        new DelegatingOAuth2TokenValidator<>(defaultValidators, audienceValidator);

    decoder.setJwtValidator(combined);
    return decoder;
}

The audience validator rejects tokens not intended for order-service. This prevents token substitution attacks — a valid token issued for a different service can't be used against this one.

Testing without a live authorization server

Testing with a real authorization server in CI requires a running Keycloak/Auth0 instance. Two approaches that don't:

@WithMockUser with JWT claims. For unit and slice tests where you control the authentication context:

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired MockMvc mockMvc;
    @MockBean OrderService orderService;

    @Test
    void listOrders_returns200_withValidToken() throws Exception {
        when(orderService.findOrdersForUser("user-123")).thenReturn(List.of());

        mockMvc.perform(get("/api/v1/orders")
                .with(jwt()
                    .jwt(j -> j.subject("user-123")
                        .claim("email", "alice@example.com")
                        .claim("scope", "orders:read"))
                    .authorities(new SimpleGrantedAuthority("SCOPE_orders:read"))))
            .andExpect(status().isOk());
    }

    @Test
    void listOrders_returns403_withoutScope() throws Exception {
        mockMvc.perform(get("/api/v1/orders")
                .with(jwt().authorities(
                    new SimpleGrantedAuthority("SCOPE_orders:write"))))
            // Missing SCOPE_orders:read
            .andExpect(status().isForbidden());
    }

    @Test
    void listOrders_returns401_withoutToken() throws Exception {
        mockMvc.perform(get("/api/v1/orders"))
            .andExpect(status().isUnauthorized());
    }
}

jwt() from spring-security-test builds a mock JWT authentication without a real token or authorization server. The .jwt(j -> ...) builder sets individual claims; .authorities(...) sets the granted authorities directly.

WireMock for JWKS endpoint. For integration tests that test the full token validation path:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWireMock(port = 0)
class OrderApiJwtIntegrationTest {

    @Autowired TestRestTemplate restTemplate;

    @Value("${wiremock.server.port}")
    int wireMockPort;

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        // Point to WireMock for JWKS
        registry.add("spring.security.oauth2.resourceserver.jwt.jwk-set-uri",
            () -> "http://localhost:${wiremock.server.port}/jwks");
    }

    @BeforeEach
    void setupJwks() {
        // Stub the JWKS endpoint with a test key pair
        stubFor(get("/jwks").willReturn(okJson(testJwksJson())));
    }

    @Test
    void listOrders_withValidJwt() {
        String token = generateTestJwt("user-123", List.of("orders:read"));

        ResponseEntity<List> response = restTemplate.exchange(
            "/api/v1/orders",
            HttpMethod.GET,
            new HttpEntity<>(headersWithToken(token)),
            List.class
        );

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
    }

    private String generateTestJwt(String subject, List<String> scopes) {
        // Generate a signed JWT with the test private key matching the stubbed JWKS
        RSAKey testKey = generateTestRSAKey();
        return new SignedJWT(
            new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(testKey.getKeyID()).build(),
            new JWTClaimsSet.Builder()
                .subject(subject)
                .issuer("http://localhost:" + wireMockPort)
                .expirationTime(Date.from(Instant.now().plusSeconds(3600)))
                .claim("scope", String.join(" ", scopes))
                .build()
        ).sign(new RSASSASigner(testKey)).serialize();
    }
}

WireMock stubs the JWKS endpoint; the test generates real JWT tokens signed with the test private key. The full validation path — JWKS fetch, signature verification, claims extraction — runs without an external service.

The audience claim — often missed

Many implementations skip audience validation. A token issued by the same authorization server for a mobile app is valid on the resource server if only issuer validation is configured. The aud claim specifies which services the token is intended for.

Configure the authorization server to include aud: ["order-service"] in tokens issued for the order service. Validate it in the resource server (as shown above). This prevents a token stolen from one service from being used against another.

The combination of issuer validation (the right authorization server issued this) and audience validation (this token was issued for this service) provides the minimal required JWT security posture.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Spotify and Klarna Set the Bar. Every Other Stockholm Startup Fights for the Same Backend Talent

Your backend candidate loved the interview. Then Spotify called. You never heard from her again.

Read more

Fat Models, Skinny Controllers — and Why I Moved Beyond Both

The fat models, skinny controllers mantra fixed one problem and created another. Here is what the architecture actually looks like when you take it to its logical conclusion.

Read more

Why Auckland Startups Have an Unfair Advantage When They Hire Async — and Most Don't Know It Yet

Auckland sits in one of the earliest timezones on the planet. Most founders see that as isolation. It's actually a scheduling superpower.

Read more

How to Spot a Failing Software Project Before It Begins

“We haven’t even started yet… so why does this feel risky?” That gut feeling is often your first — and best — warning sign.

Read more