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.
JOptimize Team
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.
<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>
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:
iss (issuer) claim against the configured URI@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; } }
@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); } }
@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); } }
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.
aud claim — a JWT issued for auth-service can be presented to order-service; always validate audienceRS256 and HS256 allows algorithm confusion attacks; enforce a single algorithm401 vs 403 correctly — unauthenticated requests should return 401 Unauthorized; authenticated users without permission should return 403 Forbidden; Spring Security handles this correctly by defaultHttpOnly cookies are immune to XSS token theft; localStorage is notSpring 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.
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.
Master Spring Boot, security, and Java performance with hands-on courses.
JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.