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

by Arif Ikhsanudin, Backend Developer

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

Why Hong Kong Startups Are Turning to Flexible Async Contractors Over Full-Time Backend Hires

Full-time backend hiring in Hong Kong has become slower and more expensive. A growing number of startups have found a working alternative.

Read more

You Don't Need a Complex Pipeline to Start. You Need a Working One.

Over-engineered pipelines built before teams understand their actual needs are a major source of CI/CD dysfunction. The path to a mature pipeline runs through a simple, working one — not around it.

Read more

Your Dockerfile Works But Your Image Is Bigger Than It Needs to Be

A working Dockerfile is not the same as a good one. Most images carry megabytes of unnecessary weight that slows builds, bloats registries, and widens the attack surface — and the fixes are straightforward once you know where to look.

Read more

You're Missing Indexes Where It Matters Most

Most teams add indexes reactively — after a slow query surfaces in production. The indexes that matter most are the ones you plan for upfront, based on your actual query patterns rather than generic advice.

Read more