Mid 6 min · May 23, 2026

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.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Add spring-boot-starter-oauth2-resource-server and configure spring.security.oauth2.resourceserver.jwt.issuer-uri
  • Use NimbusJwtDecoder with public key or JWKS URI for JWT validation
  • Annotate with @EnableWebSecurity and call .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
  • Protect endpoints with hasAuthority("SCOPE_read") or hasRole using converted claims
  • Use opaque token introspection when tokens are not self-contained JWTs
✦ Definition~90s read
What is Spring Security OAuth2 Resource Server?

An OAuth2 Resource Server is any service that accepts access tokens and serves protected resources based on the claims those tokens carry. In Spring Boot, the spring-boot-starter-oauth2-resource-server auto-configures a security filter chain that intercepts incoming HTTP requests, extracts the Authorization: Bearer <token> header, validates the token cryptographically (for JWTs) or via network call (for opaque tokens), and populates the SecurityContext with an Authentication object derived from the token claims.

Think of OAuth2 like a nightclub with a wristband system.

NimbusJwtDecoder is the default JWT decoder implementation. It fetches the authorization server's JSON Web Key Set (JWKS) endpoint on startup and caches public keys. When a JWT arrives, it verifies the signature using the matching key (identified by kid), checks exp, nbf, iss, and aud claims, and converts the payload into a Jwt object.

Spring Security's JwtAuthenticationConverter then maps scope or scp claims to GrantedAuthority objects prefixed with SCOPE_.

Opaque token introspection follows RFC 7662. Instead of verifying a signature locally, Spring POST to the introspection endpoint with the token value and client credentials, parses the JSON response, and builds the Authentication from the active token's claims. This approach trades latency for flexibility — tokens can be revoked instantly without waiting for expiry.

Plain-English First

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 jwt() to override the decoder, set audience validation, or add custom claim validators. The 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.

Always validate the audience claim
Without audience validation, a token issued for service-A is valid at service-B if both share the same IdP. Always add AudienceValidator in production to prevent token misuse across services.
Production Insight
Set spring.security.oauth2.resourceserver.jwt.jwk-set-uri directly in staging to bypass discovery and reduce startup latency by ~200ms.
Key Takeaway
Explicitly configure 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.

Use @EnableMethodSecurity not @EnableGlobalMethodSecurity
In Spring Boot 3.x, @EnableGlobalMethodSecurity is deprecated. Use @EnableMethodSecurity(prePostEnabled = true) which also enables @PostAuthorize and @Secured by default.
Production Insight
Avoid putting authorization logic purely in the filter chain for microservices — method security survives refactoring and is easier to test in isolation.
Key Takeaway
@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.

Never log or store raw tokens
Always hash tokens (SHA-256) before using as cache keys, log keys, or storing in Redis. A raw token is equivalent to a plaintext password.
Production Insight
Add a circuit breaker around the introspection endpoint — if the IdP is down and you have no cache, all requests fail. Fail-open only for internal services with network-level security.
Key Takeaway
Cache introspection responses keyed by token hash to reduce latency; wrap the cache in a circuit breaker to survive IdP downtime gracefully.

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.

GitHub may return null email
If a GitHub user has a private email, the email attribute will be null. Call the GitHub /user/emails API separately using the access token to retrieve the verified primary email.
Production Insight
Store the OAuth2 registrationId alongside the user record in your database to support multiple providers per user account (social account linking).
Key Takeaway
Use 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.

Avoid token relay to external services
Only relay tokens to services within your trust boundary. Never forward a user's access token to third-party APIs — it grants those APIs the ability to act on behalf of your user.
Production Insight
Use short-lived client credentials tokens (< 5 min TTL) for service-to-service calls and rely on OAuth2AuthorizedClientManager for automatic refresh — never hardcode or cache tokens manually.
Key Takeaway
Token relay keeps the user's security context intact through the entire service call chain; use client credentials for background service-to-service calls without user context.

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 jwt() 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.

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 jwt() setup with role assignments, making tests readable: @WithJwtUser(subject = "user-123", scopes = {"orders:read"}) is self-documenting and avoids repetitive jwt().jwt(...) builder chains in every test.

Test 401 and 403 separately
401 means unauthenticated (no token); 403 means authenticated but unauthorized (wrong scope). Test both cases explicitly — they have different root causes and fixes.
Production Insight
Add a test that verifies your endpoint returns WWW-Authenticate: Bearer header on 401 — some API clients require this header to trigger token refresh logic.
Key Takeaway
Use SecurityMockMvcRequestPostProcessors.jwt() for unit tests and Testcontainers + Keycloak for full integration testing of your OAuth2 Resource Server configuration.
● Production incidentPOST-MORTEMseverity: high

JWKS Cache Miss After Key Rotation Caused 401 Storms

Symptom
Spike of 401 Unauthorized responses starting at 02:03 UTC. All requests with tokens issued after 02:00 failed; tokens issued before succeeded. No deployment had occurred.
Assumption
The team assumed JWKS rotation was seamless because Spring Security fetches keys automatically.
Root cause
The default 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.
Fix
Configured a custom 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.
Key lesson
  • Never assume JWKS rotation is transparent.
  • Test key rotation in staging with realistic TTLs.
  • Always ensure kid values are unique across key versions, and configure a maximum cache age explicitly.
Production debug guideSymptom → root cause → fix5 entries
Symptom · 01
401 Unauthorized on all requests
Fix
Check 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.
Symptom · 02
403 Forbidden despite valid token
Fix
Print the token at jwt.io and check the 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.
Symptom · 03
Token validates locally but fails in production
Fix
Compare the 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.
Symptom · 04
NullPointerException in custom JwtAuthenticationConverter
Fix
The 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.
Symptom · 05
Opaque token introspection returns 401 from the introspection endpoint
Fix
Verify 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.
★ Debug Cheat SheetFast commands for diagnosing OAuth2 Resource Server issues in production.
401 on every request
Immediate action
Verify JWKS endpoint reachability and issuer URI config
Commands
curl -v https://auth.example.com/.well-known/openid-configuration
curl -v https://auth.example.com/.well-known/jwks.json
Fix now
Correct spring.security.oauth2.resourceserver.jwt.issuer-uri and restart
403 after successful authentication+
Immediate action
Decode token and inspect scope/authority mapping
Commands
curl -s -X POST https://auth.example.com/oauth/token -d 'grant_type=client_credentials&scope=read' | jq .access_token | cut -d. -f2 | base64 -d | jq
curl -H 'Authorization: Bearer <token>' https://myservice/actuator/env | grep -i scope
Fix now
Change hasAuthority("read") to hasAuthority("SCOPE_read") in your security config
Clock skew JWT rejection+
Immediate action
Check time sync between services and auth server
Commands
date -u && curl -sI https://auth.example.com | grep -i date
kubectl exec -it <pod> -- date -u
Fix now
Set spring.security.oauth2.resourceserver.jwt.jws-algorithms=RS256 and configure NTP on all nodes
JWKS key not found for kid+
Immediate action
Force JWKS refresh and check key ID match
Commands
curl https://auth.example.com/.well-known/jwks.json | jq '.keys[].kid'
echo <jwt_header_base64> | base64 -d | jq .kid
Fix now
Ensure kid in JWT header matches a key in the JWKS endpoint; configure unique kid per key rotation
JWT vs Opaque Token Introspection
AspectJWT (Self-Contained)Opaque Token (Introspection)
ValidationLocal crypto verifyNetwork call to IdP
Latency< 1ms5-50ms per request
RevocationWait for expiryInstant via IdP
Offline validationYesNo
ClaimsEmbedded in tokenReturned by introspection response
Token size200-500 bytes16-32 bytes
IdP dependencyOnly on key rotationEvery request
Recommended forHigh-throughput APIsSecurity-critical, low-latency-tolerant APIs

Key takeaways

1
Always validate aud (audience) claim to prevent token misuse across services sharing the same IdP.
2
NimbusJwtDecoder relies on unique kid values for JWKS cache invalidation
ensure your IdP uses distinct key IDs per rotation.
3
Scope-based authorities use the SCOPE_ prefix
hasAuthority('SCOPE_read') not hasRole('read').
4
Opaque token introspection trades latency for instant revocability
always cache responses keyed by token hash.
5
Use SessionCreationPolicy.STATELESS and disable CSRF for all REST APIs secured with JWT.

Common mistakes to avoid

6 patterns
×

Using `hasRole('ADMIN')` instead of `hasAuthority('SCOPE_admin')`

Symptom
403 Forbidden even though the token contains the correct scope
Fix
OAuth2 scopes are mapped to authorities with SCOPE_ prefix. Use hasAuthority('SCOPE_admin') or configure a custom converter that maps scopes to ROLE_ prefix.
×

Forgetting to validate the `aud` (audience) claim

Symptom
Tokens issued for other services are accepted by your API
Fix
Add AudienceValidator to DelegatingOAuth2TokenValidator in your JwtDecoder bean.
×

Not setting `SessionCreationPolicy.STATELESS`

Symptom
Resource server creates HTTP sessions, causing memory leak and horizontal scaling issues
Fix
Always set .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) in Resource Server configurations.
×

Enabling CSRF with stateless JWT authentication

Symptom
POST/PUT/DELETE requests fail with 403 CSRF token missing
Fix
Disable CSRF for stateless APIs: .csrf(csrf -> csrf.disable()). CSRF protection is only needed for browser-based session authentication.
×

Hardcoding the JWKS URI instead of using issuer-uri discovery

Symptom
Service breaks when IdP rotates or changes JWKS URI
Fix
Use 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

Symptom
Downstream service calls fail with 401 because the SecurityContext is not propagated across threads
Fix
Use ReactiveSecurityContextHolder in reactive stacks or DelegatingSecurityContextExecutor in servlet stacks to propagate context across async boundaries.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is the difference between an OAuth2 Resource Server and an Authoriz...
Q02SENIOR
How does NimbusJwtDecoder handle JWKS key rotation?
Q03JUNIOR
Explain how scope-based authorization works in Spring Security OAuth2.
Q04SENIOR
How would you prevent token misuse across different microservices sharin...
Q05JUNIOR
Describe the OAuth2 Authorization Code flow for a web app using Spring B...
Q06SENIOR
How do you implement token relay in a Spring Cloud Gateway setup?
Q07SENIOR
What is the security risk of using opaque token introspection without ca...
Q08SENIOR
How would you implement token exchange — converting a user JWT from an e...
Q09SENIOR
How do you test a Spring Security OAuth2 Resource Server endpoint withou...
Q01 of 09JUNIOR

What is the difference between an OAuth2 Resource Server and an Authorization Server?

ANSWER
The Authorization Server issues tokens (handles login, consent, token issuance). The Resource Server accepts tokens and serves protected resources. In Spring Boot, spring-boot-starter-oauth2-resource-server configures a Resource Server; Spring Authorization Server is a separate project for building Authorization Servers.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Can I use both JWT and opaque tokens in the same application?
02
How do I add custom claims to the JWT and access them in Spring Security?
03
What happens if the JWKS endpoint is down when the application starts?
04
How do I support multiple issuers (multi-tenant)?
05
Should I use OAuth2 Login or Resource Server for a REST API?
06
How do I handle token expiry gracefully on the client side?
🔥

That's Spring Security. Mark it forged?

6 min read · try the examples if you haven't

Previous
Password Encoding and BCrypt in Spring Security
2 / 4 · Spring Security
Next
Refresh Token with Spring Boot JWT