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:
- Fetches
https://auth.example.com/realms/myapp/.well-known/openid-configurationat startup - Discovers the JWKS URI from the metadata
- Downloads the public keys for token signature verification
- 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.