Back to Blog
spring-securityoauth2jwtsecurityspring-bootjava

Spring Security OAuth2 Resource Server: JWT Validation Done Right (2026)

Most Spring Boot apps validate JWTs incorrectly — wrong algorithm, missing claim validation, no token revocation. Learn the complete OAuth2 Resource Server configuration with Spring Security 6.

J

JOptimize Team

May 28, 2026· 9 min read

Spring Security's OAuth2 Resource Server support does a lot of the heavy lifting — but only if you configure it correctly. A misconfigured resource server might accept tokens signed with none algorithm, skip audience validation (accepting tokens meant for other services), or fail to enforce scopes properly. These aren't theoretical vulnerabilities.


Setup

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-resource-server</artifactId> </dependency>

Configuration: JWT with JWKS

The simplest correct configuration uses your Authorization Server's JWKS endpoint:

# application.properties spring.security.oauth2.resourceserver.jwt.issuer-uri=https://auth.myapp.com # Spring fetches keys from: https://auth.myapp.com/.well-known/openid-configuration # then: https://auth.myapp.com/.well-known/jwks.json

This approach:

  • Fetches signing keys automatically
  • Validates iss (issuer) claim against the configured URI
  • Refreshes keys when they rotate (no redeploy)

Full Security Config with Audience Validation

@Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) // Stateless API — no CSRF needed .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/v1/public/**").permitAll() .requestMatchers("/actuator/health").permitAll() .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 -> oauth2 .jwt(jwt -> jwt .decoder(jwtDecoder()) .jwtAuthenticationConverter(jwtAuthenticationConverter()) )); return http.build(); } @Bean public JwtDecoder jwtDecoder() { NimbusJwtDecoder decoder = NimbusJwtDecoder .withJwkSetUri("https://auth.myapp.com/.well-known/jwks.json") .jwsAlgorithm(SignatureAlgorithm.RS256) // Enforce algorithm — reject 'none' .build(); // Validate audience claim — reject tokens meant for other services OAuth2TokenValidator<Jwt> audienceValidator = new JwtClaimValidator<List<String>>( JwtClaimNames.AUD, aud -> aud != null && aud.contains("order-service") ); OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer("https://auth.myapp.com"); decoder.setJwtValidator( new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator) ); return decoder; } @Bean public JwtAuthenticationConverter jwtAuthenticationConverter() { JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter(); converter.setAuthoritiesClaimName("roles"); // Custom claim name converter.setAuthorityPrefix("ROLE_"); // Maps 'ADMIN' → 'ROLE_ADMIN' JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter(); jwtConverter.setJwtGrantedAuthoritiesConverter(converter); return jwtConverter; } }

Scope-Based Authorization

@RestController @RequestMapping("/api/v1/orders") public class OrderController { // Requires scope 'orders:read' OR role 'ADMIN' @GetMapping @PreAuthorize("hasAuthority('SCOPE_orders:read') or hasRole('ADMIN')") public List<OrderDto> listOrders() { ... } // Requires scope 'orders:write' @PostMapping @PreAuthorize("hasAuthority('SCOPE_orders:write')") public OrderDto createOrder(@RequestBody CreateOrderRequest req) { ... } // User can only read their own orders unless ADMIN @GetMapping("/{id}") @PreAuthorize("hasRole('ADMIN') or @orderSecurityService.canAccess(authentication, #id)") public OrderDto getOrder(@PathVariable Long id) { ... } }
@Component public class OrderSecurityService { private final OrderRepository orderRepo; public boolean canAccess(Authentication auth, Long orderId) { String userId = ((Jwt) auth.getPrincipal()).getSubject(); return orderRepo.findById(orderId) .map(order -> order.getUserId().equals(userId)) .orElse(false); } }

Extracting Claims from JWT

@RestController public class UserController { @GetMapping("/api/v1/me") public UserProfileDto getMyProfile( @AuthenticationPrincipal Jwt jwt) { String userId = jwt.getSubject(); // 'sub' claim String email = jwt.getClaimAsString("email"); List<String> roles = jwt.getClaimAsStringList("roles"); Instant issuedAt = jwt.getIssuedAt(); return new UserProfileDto(userId, email, roles); } }

Token Introspection (Opaque Tokens)

For opaque tokens (reference tokens that can be revoked):

# application.properties spring.security.oauth2.resourceserver.opaquetoken.introspection-uri=https://auth.myapp.com/oauth2/introspect spring.security.oauth2.resourceserver.opaquetoken.client-id=resource-server-client spring.security.oauth2.resourceserver.opaquetoken.client-secret=${INTROSPECTION_SECRET}

Each request calls the introspection endpoint — slower than JWT (one HTTP call per request) but supports real-time revocation.


Common Mistakes to Avoid

  • Not validating the aud claim — a JWT issued for auth-service can be presented to order-service; always validate audience
  • Accepting multiple JWT algorithms — accepting both RS256 and HS256 allows algorithm confusion attacks; enforce a single algorithm
  • Not handling 401 vs 403 correctly — unauthenticated requests should return 401 Unauthorized; authenticated users without permission should return 403 Forbidden; Spring Security handles this correctly by default
  • Storing JWT in localStorage — frontend concern, but worth noting: HttpOnly cookies are immune to XSS token theft; localStorage is not

Summary

Spring Security OAuth2 Resource Server with JWT requires four things to be correct: algorithm enforcement (RS256 only, never none), issuer validation, audience validation (your service's identifier), and scope/role authorization on endpoints. Use JWKS endpoint for automatic key rotation. Add @PreAuthorize with SCOPE_ prefixed authorities for fine-grained access control. The configuration is verbose but each piece exists for a reason.


Detect Security Misconfigurations

JOptimize flags missing audience validation, permissive authorization rules, hardcoded JWT secrets, and CSRF disabled without stateless session in Spring Security configurations.

Fix security misconfigurations before they become incidents.

Want to go deeper?

Master Spring Boot, security, and Java performance with hands-on courses.

Detect issues in your project

JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.