Spring Security OAuth2 Resource Server: JWT, Scopes & Token Introspection
Master Spring Boot OAuth2 Resource Server with JWT decoding, scope-based authorization, opaque token introspection, and token relay in microservices.
- Add
spring-boot-starter-oauth2-resource-serverand configurespring.security.oauth2.resourceserver.jwt.issuer-uri - Use
NimbusJwtDecoderwith public key or JWKS URI for JWT validation - Annotate with
@EnableWebSecurityand call.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) - Protect endpoints with
hasAuthority("SCOPE_read")orhasRoleusing converted claims - Use opaque token introspection when tokens are not self-contained JWTs
Think of OAuth2 like a nightclub with a wristband system. The authorization server (bouncer at the door) issues wristbands (tokens) after checking your ID. Your app is the bar inside — it only checks the wristband, not your ID again. JWT wristbands have the info printed right on them; opaque tokens need to be verified with the main bouncer every time.
You've set up Spring Security before — form login, in-memory users, the works. Then your team adopts microservices and suddenly every service needs to validate tokens issued by a central authorization server. You add a Bearer token check by hand, store user info in a custom header, and pray nothing changes. It breaks in staging because you forgot to update the JWKS URI. This is the production pain that spring-boot-starter-oauth2-resource-server was built to eliminate.
The OAuth2 Resource Server starter ships with everything needed to validate JWTs cryptographically, decode claims into a JwtAuthenticationToken, and expose those claims to your @PreAuthorize annotations. You get automatic JWKS rotation, clock skew handling, and a security filter chain that rejects unsigned or expired tokens before your business logic ever runs.
In microservice architectures the story gets more nuanced. A gateway receives a user token and needs to relay it downstream. Downstream services need to validate it independently without coupling to the gateway. Spring's token relay support and SecurityContext propagation handle this cleanly — but only if you configure the resource server correctly on every service.
Opaque tokens add another dimension. When the authorization server cannot embed claims in the token (for example, when the token is a random UUID stored server-side), each resource server must call the introspection endpoint. Spring Security 6 supports both flows with nearly identical configuration.
This guide walks through production-ready Resource Server configuration: NimbusJwtDecoder setup, JWKS rotation, scope extraction, custom claims conversion, OAuth2 Login integration with GitHub and Google, and token relay patterns for microservices. Every code sample runs on Spring Boot 3.x with Java 17+.
Setting Up the OAuth2 Resource Server
Adding spring-boot-starter-oauth2-resource-server to your pom.xml pulls in Spring Security's full OAuth2 stack including NimbusJwtDecoder, JWT validation filters, and auto-configuration. With a single property — spring.security.oauth2.resourceserver.jwt.issuer-uri — Spring Boot fetches the OpenID Connect discovery document, extracts the JWKS URI, and configures the decoder automatically.
For production, always declare a SecurityFilterChain bean explicitly rather than relying on the auto-configured one. This gives you control over which endpoints require authentication, what scopes are needed, and how exceptions are handled. The auto-configured chain requires every endpoint to be authenticated — a common surprise when you add an /actuator/health endpoint.
The oauth2ResourceServer DSL in Spring Security 6 uses lambdas. Pass a customizer to to override the decoder, set audience validation, or add custom claim validators. The jwt()JwtDecoder bean is separate from the filter chain — you can override just the decoder while keeping the rest of the auto-configuration.
Always set audiences validation in production. Without it, a token issued for a different resource server (but signed by the same IdP) is accepted by your service. Use JwtValidators.createDefaultWithIssuer() combined with new AudienceValidator(Set.of("my-api")) and wrap both in DelegatingOAuth2TokenValidator.
AudienceValidator in production to prevent token misuse across services.spring.security.oauth2.resourceserver.jwt.jwk-set-uri directly in staging to bypass discovery and reduce startup latency by ~200ms.JwtDecoder with audience validation and a custom JwtAuthenticationConverter — never rely on defaults in production.Scope-Based Authorization with @PreAuthorize
Spring Security's method security integrates cleanly with OAuth2 scopes. Once you enable @EnableMethodSecurity, you can place @PreAuthorize("hasAuthority('SCOPE_read')") on service methods, controllers, or even repository methods. This pushes authorization logic close to the domain rather than scattering it across the security filter chain configuration.
For complex authorization rules combining scopes with business data (e.g., a user can only read their own orders), use @PostAuthorize or Spring Security's @PreAuthorize with SpEL expressions accessing the Authentication object. Extract the subject (sub) claim via authentication.name or directly from the JwtAuthenticationToken.
A common pattern is to create a custom @CurrentUser annotation backed by @AuthenticationPrincipal. This gives controller methods clean access to the JWT claims without manually extracting from the SecurityContext. Pair this with a custom principal class that wraps the JWT and exposes typed claim accessors.
Scope hierarchies are not natively supported by Spring Security's OAuth2 layer, but you can implement them by expanding scopes in a custom JwtGrantedAuthoritiesConverter. For example, admin scope implicitly grants read and write — your converter can synthesize all implied authorities when it detects the admin scope.
@EnableGlobalMethodSecurity is deprecated. Use @EnableMethodSecurity(prePostEnabled = true) which also enables @PostAuthorize and @Secured by default.@PreAuthorize with scope-based authorities gives fine-grained, testable authorization that survives refactoring better than URL-pattern matching.Opaque Token Introspection
When the authorization server issues random reference tokens instead of JWTs, the resource server cannot validate them locally. Spring Security supports RFC 7662 introspection out of the box. Configure spring.security.oauth2.resourceserver.opaquetoken.introspection-uri, client-id, and client-secret, then switch the DSL to .oauth2ResourceServer(oauth2 -> oauth2.opaqueToken(...)). Spring posts the token to the introspection endpoint and maps the response to OAuth2AuthenticatedPrincipal.
The cost is a network round-trip per request. In high-throughput services this is significant. Mitigate with a short-lived local cache: wrap the default OpaqueTokenIntrospector with a Caffeine-backed decorator that caches introspection responses keyed by token hash for up to the token's remaining TTL.
Custom claims in the introspection response are automatically available on OAuth2AuthenticatedPrincipal.getAttribute("custom_claim"). If you need to transform introspection response claims into GrantedAuthority objects, implement a custom OpaqueTokenIntrospector that delegates to NimbusOpaqueTokenIntrospector and then post-processes the result.
Security note: always hash the token before using it as a cache key. Storing raw tokens in memory or logs is a security anti-pattern — if your cache is dumped, attackers gain valid tokens.
OAuth2 Login with GitHub and Google
Spring Boot's spring-boot-starter-oauth2-client enables OAuth2 Login — delegating user authentication to external providers like GitHub or Google. Unlike the Resource Server (which validates tokens), OAuth2 Login initiates the authorization code flow, exchanges the code for an access token, fetches user info, and creates a Spring Security session backed by OAuth2User or OidcUser.
Register each provider under spring.security.oauth2.client.registration. Spring Boot pre-configures commonOAuth2Provider entries for Google, GitHub, Facebook, and Okta — you only need to supply client-id and client-secret. For custom IdPs, define a provider block with authorization URI, token URI, and user-info URI.
Extract the authenticated user's information in controllers using @AuthenticationPrincipal OAuth2User for standard OAuth2 providers or @AuthenticationPrincipal OidcUser for OIDC providers like Google. The OidcUser exposes standard claims directly (user.getEmail(), user.getName()) without manual attribute mapping.
For microservices combining OAuth2 Login at the gateway with JWT-based downstream services, implement token exchange: after the user authenticates via OAuth2 Login, issue an internal JWT containing the user's id, email, and roles, then propagate that JWT in service-to-service calls. This decouples downstream services from the external OAuth2 provider.
email attribute will be null. Call the GitHub /user/emails API separately using the access token to retrieve the verified primary email.registrationId alongside the user record in your database to support multiple providers per user account (social account linking).OAuth2UserService to bridge external OAuth2 identities to your local user model, assigning roles from your own database rather than trusting provider claims blindly.Token Relay in Microservices
In a microservice architecture, the gateway authenticates the user and receives a JWT. Downstream services need that token to authorize requests independently. Token relay means forwarding the original access token from the incoming request to outgoing service calls.
Spring Security's OAuth2AuthorizedClientManager combined with ServerOAuth2AuthorizedClientExchangeFilterFunction (for reactive stacks) or a ClientHttpRequestInterceptor (for servlet stacks) handles token relay automatically for client credentials flow. For user-context relay, extract the token from the SecurityContext and inject it into WebClient or RestTemplate headers.
The spring-cloud-starter-gateway with TokenRelayGatewayFilterFactory provides turnkey relay: add - TokenRelay= to your route filters and the gateway appends Authorization: Bearer <token> to all proxied requests. Downstream services configured as Resource Servers validate the token independently.
For service-to-service calls without user context (batch jobs, background tasks), use the Client Credentials grant. Configure a separate OAuth2 client registration with authorization-grant-type: client_credentials and inject OAuth2AuthorizedClientManager to obtain and auto-refresh tokens. The manager handles token expiry transparently.
OAuth2AuthorizedClientManager for automatic refresh — never hardcode or cache tokens manually.Testing OAuth2 Resource Server Endpoints
Testing secured endpoints requires injecting mock tokens or using Spring Security's test support. @WithMockUser does not work for OAuth2 Resource Servers because it creates a UsernamePasswordAuthenticationToken, not a JwtAuthenticationToken. Use SecurityMockMvcRequestPostProcessors.jwt() instead, which builds a real JwtAuthenticationToken with configurable claims and authorities.
For integration tests, @SpringBootTest with @AutoConfigureMockMvc combined with post-processor gives full coverage of the security filter chain without a real authorization server. You can set specific JWT claims, authorities, and subject to simulate different user roles.jwt()
For actual token validation (testing the decoder configuration), use Testcontainers with a real Keycloak instance or WireMock to stub the JWKS endpoint and issue real JWTs signed with a test key. This validates your audience and issuer configuration end-to-end.
Spring Security's @WithSecurityContext allows creating custom annotations that combine setup with role assignments, making tests readable: jwt()@WithJwtUser(subject = "user-123", scopes = {"orders:read"}) is self-documenting and avoids repetitive builder chains in every test.jwt().jwt(...)
WWW-Authenticate: Bearer header on 401 — some API clients require this header to trigger token refresh logic.SecurityMockMvcRequestPostProcessors.jwt() for unit tests and Testcontainers + Keycloak for full integration testing of your OAuth2 Resource Server configuration.JWKS Cache Miss After Key Rotation Caused 401 Storms
NimbusJwtDecoder caches keys for up to 5 minutes and only refreshes when a kid is unknown. The authorization server rotated to a new key but kept the old kid value (a misconfiguration on the IdP side). Spring never triggered a cache refresh and kept failing validation with the stale key.NimbusJwtDecoder with jwsAlgorithm set explicitly and added a RestOperationsResourceRetriever with a 30-second cache TTL. Also fixed the IdP to use unique kid values per key. Added a health check that pre-validates a test JWT on startup.- Never assume JWKS rotation is transparent.
- Test key rotation in staging with realistic TTLs.
- Always ensure
kidvalues are unique across key versions, and configure a maximum cache age explicitly.
spring.security.oauth2.resourceserver.jwt.issuer-uri is reachable from the service. Run curl <issuer-uri>/.well-known/openid-configuration inside the container. If the issuer URI is wrong or unreachable, the decoder fails to initialize and rejects all tokens. Fix the URI and ensure network policies allow the JWKS fetch.scope or scp claim. Spring prefixes scopes with SCOPE_, so a scope of read becomes SCOPE_read. Verify your hasAuthority call uses the prefixed form. If you use hasRole, ensure your JwtAuthenticationConverter maps claims to roles with the ROLE_ prefix.iss claim in the token against spring.security.oauth2.resourceserver.jwt.issuer-uri. A trailing slash mismatch (https://auth.example.com vs https://auth.example.com/) will cause issuer validation to fail. Also verify clock synchronization — a clock skew greater than 5 seconds causes nbf/exp failures.scope claim may be absent or formatted differently (space-separated string vs array). Guard with jwt.hasClaim("scope") ? ... : Collections.emptyList(). Check whether your IdP uses scope or scp as the claim name. Add a unit test that exercises the converter with a minimal JWT containing only the claims your code touches.spring.security.oauth2.resourceserver.opaquetoken.client-id and client-secret are correct. Use curl -u client:secret -d 'token=<value>' <introspection-uri> to test independently. Ensure the introspection endpoint returns {"active":true} for valid tokens; some IdPs return 200 with active:false instead of 401.curl -v https://auth.example.com/.well-known/openid-configurationcurl -v https://auth.example.com/.well-known/jwks.jsonspring.security.oauth2.resourceserver.jwt.issuer-uri and restartKey takeaways
aud (audience) claim to prevent token misuse across services sharing the same IdP.NimbusJwtDecoder relies on unique kid values for JWKS cache invalidationSCOPE_ prefixhasAuthority('SCOPE_read') not hasRole('read').SessionCreationPolicy.STATELESS and disable CSRF for all REST APIs secured with JWT.Common mistakes to avoid
6 patternsUsing `hasRole('ADMIN')` instead of `hasAuthority('SCOPE_admin')`
SCOPE_ prefix. Use hasAuthority('SCOPE_admin') or configure a custom converter that maps scopes to ROLE_ prefix.Forgetting to validate the `aud` (audience) claim
AudienceValidator to DelegatingOAuth2TokenValidator in your JwtDecoder bean.Not setting `SessionCreationPolicy.STATELESS`
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) in Resource Server configurations.Enabling CSRF with stateless JWT authentication
.csrf(csrf -> csrf.disable()). CSRF protection is only needed for browser-based session authentication.Hardcoding the JWKS URI instead of using issuer-uri discovery
spring.security.oauth2.resourceserver.jwt.issuer-uri to enable OpenID Connect discovery, which fetches the JWKS URI dynamically.Not handling token relay in async/reactive contexts
SecurityContext is not propagated across threadsReactiveSecurityContextHolder in reactive stacks or DelegatingSecurityContextExecutor in servlet stacks to propagate context across async boundaries.Interview Questions on This Topic
What is the difference between an OAuth2 Resource Server and an Authorization Server?
spring-boot-starter-oauth2-resource-server configures a Resource Server; Spring Authorization Server is a separate project for building Authorization Servers.Frequently Asked Questions
That's Spring Security. Mark it forged?
6 min read · try the examples if you haven't