# Spring Security 6: OAuth2 Resource Server Setup > Practical guide to configuring an OAuth2 Resource Server with Spring Security 6. JWT validation, issuer configuration, scope management, and Keycloak integration. - Published: 2026-03-16 - Updated: 2026-03-31 - Author: SharpSkill - Tags: spring security, oauth2, resource server, jwt, spring boot - Reading time: 14 min --- An OAuth2 Resource Server protects APIs by validating JWT tokens issued by an external Authorization Server such as Keycloak, Auth0, or Okta. Unlike custom JWT authentication where the application generates its own tokens, the Resource Server delegates identity management entirely to a dedicated Identity Provider (IdP). > **Resource Server vs Custom JWT** > > A Resource Server never generates tokens. It only validates them. This separation of concerns strengthens security and simplifies architecture by centralizing identity management. ## OAuth2 Resource Server Architecture The OAuth2 flow involves three main actors. The client (frontend or mobile application) obtains an access token from the Authorization Server. It then includes this token in every request to the Resource Server. The Resource Server validates the token by verifying its signature using the Authorization Server's public key. This architecture offers several advantages. The Resource Server remains stateless since all necessary information is contained within the JWT token. Validation occurs without network calls to the IdP thanks to asymmetric signature verification. Multiple APIs can share the same Authorization Server, simplifying user management. ``` ┌──────────────┐ 1. Login ┌─────────────────────┐ │ Client │ ───────────────► │ Authorization │ │ (SPA/App) │ │ Server (Keycloak) │ │ │ ◄─────────────────│ │ └──────────────┘ 2. JWT Token └─────────────────────┘ │ │ │ 3. Request + Bearer Token │ ▼ ▼ ┌──────────────────────┐ ┌─────────────────────┐ │ Resource Server │ ◄─────── │ JWKS Endpoint │ │ (Spring Boot API) │ 4. Public Keys (cached) │ └──────────────────────┘ └─────────────────────┘ ``` The Resource Server downloads public keys at startup and caches them, avoiding network calls for each request. ## Maven Dependencies for OAuth2 Resource Server Configuring a Resource Server requires two specific dependencies. The `oauth2-resource-server` starter provides the base infrastructure, while `oauth2-jose` contains classes for decoding and validating JWTs. ```xml org.springframework.boot spring-boot-starter-oauth2-resource-server org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-validation ``` No additional dependencies like JJWT are required. Spring Security uses the Nimbus JOSE+JWT library included in `spring-security-oauth2-jose` for token handling. ## Basic Configuration with issuer-uri The minimal Resource Server configuration requires just two lines. Spring Security automatically discovers IdP endpoints via the OpenID Connect Discovery protocol. ```yaml # application.yml spring: security: oauth2: resourceserver: jwt: # Authorization Server URI # Spring automatically retrieves public keys via /.well-known/openid-configuration issuer-uri: https://keycloak.example.com/realms/myrealm ``` From the `issuer-uri`, Spring Security makes a request to `{issuer-uri}/.well-known/openid-configuration` to obtain the Authorization Server's metadata. It then extracts the JWKS (JSON Web Key Set) URL containing the public keys used to validate token signatures. ```java // SecurityConfig.java @Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http // Disable CSRF for stateless APIs .csrf(csrf -> csrf.disable()) // Authorization rules .authorizeHttpRequests(auth -> auth // Public endpoints .requestMatchers("/api/public/**").permitAll() .requestMatchers("/actuator/health").permitAll() // All other requests require a valid token .anyRequest().authenticated() ) // Enable OAuth2 JWT validation .oauth2ResourceServer(oauth2 -> oauth2 .jwt(Customizer.withDefaults()) ) // Stateless mode required for Resource Servers .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) .build(); } } ``` This configuration is sufficient to protect an API. Spring Security automatically validates the token signature, verifies timestamps (`exp`, `nbf`, `iat`), and ensures the `iss` claim matches the configured `issuer-uri`. > **Startup Without IdP** > > By default, the application fails to start if the Authorization Server is unreachable. To allow independent startup, configure `jwk-set-uri` explicitly instead of `issuer-uri`. ## Advanced Configuration with jwk-set-uri When the Authorization Server does not support OpenID Connect Discovery or the application must start without network dependency on the IdP, explicit JWKS configuration is preferable. ```yaml # application.yml spring: security: oauth2: resourceserver: jwt: # Direct URL to JWKS (public keys) jwk-set-uri: https://keycloak.example.com/realms/myrealm/protocol/openid-connect/certs # Expected value of the "iss" claim in tokens issuer-uri: https://keycloak.example.com/realms/myrealm # Allowed audiences ("aud" claim) audiences: - my-api - account ``` This approach provides more control. Audience validation prevents the use of tokens intended for other services. A token issued for the `frontend-app` application will be rejected if the API only accepts `my-api` and `account` audiences. ```java // SecurityConfig.java @Configuration @EnableWebSecurity public class SecurityConfig { @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") private String issuerUri; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/public/**").permitAll() .anyRequest().authenticated() ) // Custom JWT configuration .oauth2ResourceServer(oauth2 -> oauth2 .jwt(jwt -> jwt .jwtAuthenticationConverter(jwtAuthenticationConverter()) ) ) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) .build(); } // Custom converter to extract authorities from the token @Bean public JwtAuthenticationConverter jwtAuthenticationConverter() { JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter(); // SCOPE_ prefix for authorities (e.g., SCOPE_read, SCOPE_write) grantedAuthoritiesConverter.setAuthorityPrefix("SCOPE_"); // Claim containing scopes (OAuth2 standard) grantedAuthoritiesConverter.setAuthoritiesClaimName("scope"); JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter(); jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter( grantedAuthoritiesConverter ); return jwtAuthenticationConverter; } } ``` The `JwtAuthenticationConverter` transforms token claims into `GrantedAuthority` objects usable in security expressions. ## Extracting Keycloak Roles Keycloak structures roles differently from the OAuth2 standard. Roles can be found in `realm_access.roles` (realm roles) or `resource_access.{client}.roles` (client-specific roles). A custom converter extracts this information. ```java // KeycloakJwtAuthenticationConverter.java @Component public class KeycloakJwtAuthenticationConverter implements Converter { private static final String REALM_ACCESS_CLAIM = "realm_access"; private static final String RESOURCE_ACCESS_CLAIM = "resource_access"; private static final String ROLES_CLAIM = "roles"; @Value("${keycloak.client-id}") private String clientId; @Override public AbstractAuthenticationToken convert(Jwt jwt) { // Combine realm and client roles Collection authorities = Stream.concat( extractRealmRoles(jwt).stream(), extractClientRoles(jwt).stream() ).collect(Collectors.toSet()); // Return authentication token with extracted authorities return new JwtAuthenticationToken(jwt, authorities, extractUsername(jwt)); } // Extract realm-level roles private Collection extractRealmRoles(Jwt jwt) { Map realmAccess = jwt.getClaim(REALM_ACCESS_CLAIM); if (realmAccess == null) { return Collections.emptyList(); } @SuppressWarnings("unchecked") List roles = (List) realmAccess.get(ROLES_CLAIM); if (roles == null) { return Collections.emptyList(); } // ROLE_ prefix for compatibility with hasRole() return roles.stream() .map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase())) .collect(Collectors.toList()); } // Extract client-specific roles private Collection extractClientRoles(Jwt jwt) { Map resourceAccess = jwt.getClaim(RESOURCE_ACCESS_CLAIM); if (resourceAccess == null) { return Collections.emptyList(); } @SuppressWarnings("unchecked") Map clientAccess = (Map) resourceAccess.get(clientId); if (clientAccess == null) { return Collections.emptyList(); } @SuppressWarnings("unchecked") List roles = (List) clientAccess.get(ROLES_CLAIM); if (roles == null) { return Collections.emptyList(); } return roles.stream() .map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase())) .collect(Collectors.toList()); } // Extract username from token private String extractUsername(Jwt jwt) { // Keycloak uses "preferred_username" by default String username = jwt.getClaimAsString("preferred_username"); if (username != null) { return username; } // Fallback to subject (usually the user UUID) return jwt.getSubject(); } } ``` Integration into the security configuration is done via the `jwtAuthenticationConverter` parameter. ```java // SecurityConfig.java @Configuration @EnableWebSecurity @EnableMethodSecurity @RequiredArgsConstructor public class SecurityConfig { private final KeycloakJwtAuthenticationConverter keycloakConverter; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/public/**").permitAll() // Protection by Keycloak role .requestMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 -> oauth2 .jwt(jwt -> jwt // Use Keycloak converter .jwtAuthenticationConverter(keycloakConverter) ) ) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) .build(); } } ``` Extracted roles are now usable with `@PreAuthorize("hasRole('ADMIN')")` annotations or in `requestMatchers().hasRole()` expressions. ## Custom Token Validation Additional validations may be required depending on business needs: custom claim verification, audience validation, or maximum lifetime control. ```java // CustomJwtValidator.java @Component public class CustomJwtValidator implements OAuth2TokenValidator { private static final OAuth2Error AUDIENCE_ERROR = new OAuth2Error("invalid_token", "Token audience is not valid", null); private static final OAuth2Error CUSTOM_CLAIM_ERROR = new OAuth2Error("invalid_token", "Required custom claim missing", null); @Value("${app.jwt.required-audience}") private String requiredAudience; @Value("${app.jwt.required-tenant-claim:tenant_id}") private String tenantClaimName; @Override public OAuth2TokenValidatorResult validate(Jwt jwt) { List errors = new ArrayList<>(); // Audience validation if (!validateAudience(jwt)) { errors.add(AUDIENCE_ERROR); } // Custom business claim validation if (!validateTenantClaim(jwt)) { errors.add(CUSTOM_CLAIM_ERROR); } if (!errors.isEmpty()) { return OAuth2TokenValidatorResult.failure(errors); } return OAuth2TokenValidatorResult.success(); } private boolean validateAudience(Jwt jwt) { List audiences = jwt.getAudience(); return audiences != null && audiences.contains(requiredAudience); } private boolean validateTenantClaim(Jwt jwt) { // Check for required business claim String tenantId = jwt.getClaimAsString(tenantClaimName); return tenantId != null && !tenantId.isBlank(); } } ``` The custom validator integrates into the `JwtDecoder` configuration. ```java // JwtConfig.java @Configuration @RequiredArgsConstructor public class JwtConfig { private final CustomJwtValidator customValidator; @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") private String issuerUri; @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") private String jwkSetUri; @Bean public JwtDecoder jwtDecoder() { // Create decoder with JWKS URL NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder .withJwkSetUri(jwkSetUri) .build(); // Combine default validators with custom validator OAuth2TokenValidator defaultValidators = JwtValidators.createDefaultWithIssuer(issuerUri); OAuth2TokenValidator combinedValidator = new DelegatingOAuth2TokenValidator<>(defaultValidators, customValidator); jwtDecoder.setJwtValidator(combinedValidator); return jwtDecoder; } } ``` > **Validation Performance** > > Synchronous validations block the processing thread. For validations requiring external calls (database, third-party services), prefer an asynchronous filter or downstream verification. ## Accessing Token Information in Controllers Spring Security provides several methods to access JWT token information in controllers. ```java // UserController.java @RestController @RequestMapping("/api/users") public class UserController { // Direct JWT injection via @AuthenticationPrincipal @GetMapping("/me") public ResponseEntity getCurrentUser( @AuthenticationPrincipal Jwt jwt ) { String userId = jwt.getSubject(); String email = jwt.getClaimAsString("email"); String username = jwt.getClaimAsString("preferred_username"); List roles = extractRoles(jwt); return ResponseEntity.ok(new UserInfoResponse( userId, email, username, roles )); } // Alternative with JwtAuthenticationToken to access authorities @GetMapping("/profile") public ResponseEntity getProfile( JwtAuthenticationToken authentication ) { Jwt jwt = authentication.getToken(); Collection authorities = authentication.getAuthorities() .stream() .map(GrantedAuthority::getAuthority) .toList(); return ResponseEntity.ok(new ProfileResponse( jwt.getSubject(), jwt.getClaimAsString("name"), authorities )); } // Access scopes for conditional business logic @GetMapping("/data") @PreAuthorize("hasAuthority('SCOPE_read')") public ResponseEntity getData( @AuthenticationPrincipal Jwt jwt ) { boolean canWrite = hasScope(jwt, "write"); // Business logic adapted according to permissions DataResponse response = buildDataResponse(jwt.getSubject(), canWrite); return ResponseEntity.ok(response); } private List extractRoles(Jwt jwt) { Map realmAccess = jwt.getClaim("realm_access"); if (realmAccess == null) { return Collections.emptyList(); } @SuppressWarnings("unchecked") List roles = (List) realmAccess.get("roles"); return roles != null ? roles : Collections.emptyList(); } private boolean hasScope(Jwt jwt, String scope) { String scopes = jwt.getClaimAsString("scope"); return scopes != null && scopes.contains(scope); } } ``` ```java // UserInfoResponse.java public record UserInfoResponse( String userId, String email, String username, List roles ) {} // ProfileResponse.java public record ProfileResponse( String userId, String name, Collection authorities ) {} ``` The `@AuthenticationPrincipal` annotation avoids manually retrieving authentication from the `SecurityContextHolder`. ## OAuth2 Authentication Error Handling Specific error handling improves the developer experience for API consumers. ```java // OAuth2SecurityExceptionHandler.java @RestControllerAdvice public class OAuth2SecurityExceptionHandler { private static final Logger log = LoggerFactory.getLogger(OAuth2SecurityExceptionHandler.class); // Missing token or invalid format @ExceptionHandler(AuthenticationException.class) @ResponseStatus(HttpStatus.UNAUTHORIZED) public ErrorResponse handleAuthenticationException(AuthenticationException ex) { log.warn("Authentication failed: {}", ex.getMessage()); return new ErrorResponse( "UNAUTHORIZED", "Authentication required. Provide a valid Bearer token.", Map.of("error", ex.getMessage()) ); } // Access denied despite valid token @ExceptionHandler(AccessDeniedException.class) @ResponseStatus(HttpStatus.FORBIDDEN) public ErrorResponse handleAccessDeniedException(AccessDeniedException ex) { log.warn("Access denied: {}", ex.getMessage()); return new ErrorResponse( "FORBIDDEN", "Insufficient permissions for this resource", null ); } // OAuth2-specific error (expired token, invalid signature, etc.) @ExceptionHandler(OAuth2AuthenticationException.class) @ResponseStatus(HttpStatus.UNAUTHORIZED) public ErrorResponse handleOAuth2Exception(OAuth2AuthenticationException ex) { OAuth2Error error = ex.getError(); log.warn("OAuth2 authentication error: {} - {}", error.getErrorCode(), error.getDescription()); String message = switch (error.getErrorCode()) { case "invalid_token" -> "Token is invalid or expired"; case "insufficient_scope" -> "Token does not have required scopes"; default -> "Authentication failed"; }; return new ErrorResponse( error.getErrorCode().toUpperCase(), message, Map.of("details", error.getDescription()) ); } } // ErrorResponse.java public record ErrorResponse( String code, String message, Map details ) {} ``` Standardized OAuth2 error codes (`invalid_token`, `insufficient_scope`) facilitate client-side processing. ## Multi-Environment Configuration Configuration varies by environment. In development, a local Keycloak server can be used, while in production, a managed IdP like Auth0 takes over. ```yaml # application.yml (common configuration) app: jwt: required-audience: my-api --- # application-dev.yml spring: config: activate: on-profile: dev security: oauth2: resourceserver: jwt: issuer-uri: http://localhost:8180/realms/dev-realm keycloak: client-id: my-api-dev logging: level: org.springframework.security: DEBUG --- # application-prod.yml spring: config: activate: on-profile: prod security: oauth2: resourceserver: jwt: issuer-uri: ${OAUTH2_ISSUER_URI} audiences: ${OAUTH2_AUDIENCES:my-api} keycloak: client-id: ${KEYCLOAK_CLIENT_ID} logging: level: org.springframework.security: WARN ``` In production, sensitive values come from environment variables or a secrets manager. ## Integration Tests with Mocked JWTs Integration tests use Spring Security Test utilities to simulate valid JWT tokens. ```java // ResourceServerIntegrationTest.java @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) @AutoConfigureMockMvc class ResourceServerIntegrationTest { @Autowired private MockMvc mockMvc; @Test void shouldRejectRequestWithoutToken() throws Exception { mockMvc.perform(get("/api/users/me")) .andExpect(status().isUnauthorized()); } @Test @WithMockJwtAuth( claims = @OpenIdClaims(sub = "user-123", preferredUsername = "john.doe"), authorities = {"ROLE_USER"} ) void shouldAllowAuthenticatedUser() throws Exception { mockMvc.perform(get("/api/users/me")) .andExpect(status().isOk()) .andExpect(jsonPath("$.userId").value("user-123")) .andExpect(jsonPath("$.username").value("john.doe")); } @Test void shouldAllowAccessWithValidJwt() throws Exception { mockMvc.perform(get("/api/users/me") .with(jwt() .jwt(jwt -> jwt .subject("user-456") .claim("preferred_username", "jane.doe") .claim("email", "jane@example.com") ) .authorities(new SimpleGrantedAuthority("ROLE_USER")) )) .andExpect(status().isOk()) .andExpect(jsonPath("$.email").value("jane@example.com")); } @Test void shouldDenyAccessWithoutAdminRole() throws Exception { mockMvc.perform(get("/api/admin/users") .with(jwt() .authorities(new SimpleGrantedAuthority("ROLE_USER")) )) .andExpect(status().isForbidden()); } @Test void shouldAllowAdminAccess() throws Exception { mockMvc.perform(get("/api/admin/users") .with(jwt() .authorities(new SimpleGrantedAuthority("ROLE_ADMIN")) )) .andExpect(status().isOk()); } } ``` The `jwt()` utility from `spring-security-test` creates mocked tokens without requiring an Authorization Server. ```java // SecurityTestConfig.java @TestConfiguration public class SecurityTestConfig { // Mocked JwtDecoder for tests @Bean @Primary public JwtDecoder jwtDecoder() { return token -> { // Return a mocked JWT for tests return Jwt.withTokenValue(token) .header("alg", "RS256") .subject("test-user") .claim("scope", "read write") .build(); }; } } ``` ## Conclusion Configuring an OAuth2 Resource Server with Spring Security 6 centralizes token validation and simplifies API security. The Authorization Server handles authentication and token issuance, while the API focuses on validation and identity information extraction. **Deployment Checklist:** - ✅ Configure `issuer-uri` or `jwk-set-uri` according to requirements - ✅ Validate audience to prevent misuse of tokens from other services - ✅ Custom converter to extract Keycloak roles - ✅ OAuth2 error handling with standardized codes - ✅ Integration tests with mocked tokens - ✅ HTTPS mandatory between client and Resource Server - ✅ Environment-specific configuration - ✅ Logging of failed authentication attempts This architecture naturally integrates into a microservices ecosystem where multiple APIs share the same Identity Provider. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/en/blog/spring-boot/spring-security-6-oauth2-resource-server