JWT, OAuth2 & Securing APIs

Spring Security as a Resource Server

18 min Lesson 9 of 13

Spring Security as a Resource Server

In a modern OAuth2 architecture your application rarely issues tokens itself. Instead, a dedicated Authorization Server (Keycloak, Auth0, Okta, your own Spring Authorization Server) hands out JWTs, and every downstream Resource Server — the APIs that actually hold the data — must independently verify each incoming bearer token on every request. This lesson covers exactly that role: configuring Spring Security 6 to act as a stateless OAuth2 Resource Server that validates bearer JWTs without calling back to the Authorization Server on each request.

Why a Resource Server Does Not Trust the Token Blindly

A bearer token is just a string. Anyone who intercepts it can reuse it. The Resource Server's job is to answer three questions before honoring a request:

  1. Is the signature valid? The token was signed with the Authorization Server's private key. Verifying against the public key proves it was not tampered with.
  2. Has the token expired? The exp claim is checked against the server clock.
  3. Is this token meant for me? The aud (audience) claim names the Resource Server(s) that may accept it.
Stateless verification: Because the Authorization Server signs the JWT with an asymmetric key (RS256 or ES256), the Resource Server only needs the public key — never the private key. It can verify millions of tokens per second with no network call to the Authorization Server. This is the fundamental scalability advantage of JWTs over opaque tokens.

Adding the Dependency

Spring Security's resource-server support lives in spring-security-oauth2-resource-server, which is bundled with the spring-boot-starter-oauth2-resource-server starter. Add it to your pom.xml:

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

That single starter pulls in the JWT decoder, the bearer-token filter, and all required Spring Security converters.

Configuring the JWT Decoder via OIDC Discovery

The simplest — and recommended — approach is to point Spring at the Authorization Server's OIDC well-known endpoint. Spring downloads the public-key set (JWKS) automatically, caches it, and rotates it when the Authorization Server rolls its keys.

# application.yml spring: security: oauth2: resourceserver: jwt: issuer-uri: https://auth.example.com/realms/myrealm

Spring derives the JWKS URI from issuer-uri + /.well-known/openid-configuration. It also validates that every incoming JWT's iss claim matches this URI — a free forgery check you would otherwise have to write yourself.

Offline / static public key: If your Authorization Server is not OIDC-compliant or is unreachable at startup, you can supply the JWK Set URI or even a raw RSA public key directly:
spring: security: oauth2: resourceserver: jwt: jwk-set-uri: https://auth.example.com/realms/myrealm/protocol/openid-connect/certs
Using jwk-set-uri skips issuer validation, so make sure you add it manually in your security config.

The Security Configuration Class

With the starter on the classpath and issuer-uri set, Spring Boot auto-configures a resource server that requires a valid JWT on every request. In practice you will almost always override the defaults to add fine-grained authorization rules:

import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; @Configuration @EnableWebSecurity @EnableMethodSecurity // enables @PreAuthorize on controller methods public class ResourceServerConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http // Resource servers are stateless — never create an HTTP session .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // Disable CSRF: bearer tokens make it unnecessary .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth .requestMatchers("/actuator/health", "/actuator/info").permitAll() .requestMatchers("/api/public/**").permitAll() .anyRequest().authenticated() ) // Declare this application as an OAuth2 Resource Server .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthConverter()))); return http.build(); } @Bean public JwtAuthenticationConverter jwtAuthConverter() { JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter(); // Keycloak puts roles in "realm_access.roles"; adjust per your AS grantedAuthoritiesConverter.setAuthoritiesClaimName("roles"); grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_"); JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); converter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter); return converter; } }
Do NOT disable CSRF and forget about token theft. Disabling CSRF is correct for stateless APIs because there are no cookies to hijack — but only if clients always send the token in the Authorization: Bearer <token> header. If your API is also consumed by a browser-based front-end that stores the JWT in a cookie, you need CSRF protection back on.

Understanding JwtAuthenticationConverter

After signature and expiry validation, Spring must turn the JWT's claims into a Spring Security Authentication object — specifically a JwtAuthenticationToken. The JwtAuthenticationConverter controls that mapping. Its key responsibility is extracting granted authorities (roles/scopes) from the token claims.

Different Authorization Servers embed roles differently:

  • Keycloak: realm_access.roles (nested JSON)
  • Auth0: custom claim, e.g. https://example.com/roles
  • Spring Authorization Server: scope (space-separated string, prefixed with SCOPE_)

For complex claim extraction you can implement Converter<Jwt, Collection<GrantedAuthority>> directly and wire it into the converter. Here is an example that reads Keycloak's nested structure:

import org.springframework.core.convert.converter.Converter; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.oauth2.jwt.Jwt; import java.util.Collection; import java.util.List; import java.util.Map; import java.util.stream.Collectors; public class KeycloakRolesConverter implements Converter<Jwt, Collection<GrantedAuthority>> { @Override public Collection<GrantedAuthority> convert(Jwt jwt) { Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access"); if (realmAccess == null || !realmAccess.containsKey("roles")) { return List.of(); } @SuppressWarnings("unchecked") List<String> roles = (List<String>) realmAccess.get("roles"); return roles.stream() .map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase())) .collect(Collectors.toList()); } }

Audience Validation — Restricting Which Tokens Are Accepted

By default, Spring's JWT decoder does not validate the aud claim. In a microservices environment where one Authorization Server issues tokens for many Resource Servers, skipping audience validation means Service A's token could be replayed against Service B. Always configure an audience validator:

import org.springframework.security.oauth2.core.OAuth2TokenValidator; import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.JwtDecoders; import org.springframework.security.oauth2.jwt.JwtValidators; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; @Bean JwtDecoder jwtDecoder( @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") String issuerUri) { NimbusJwtDecoder decoder = (NimbusJwtDecoder) JwtDecoders.fromIssuerLocation(issuerUri); OAuth2TokenValidator<Jwt> audienceValidator = jwt -> { List<String> audiences = jwt.getAudience(); if (audiences.contains("orders-api")) { return OAuth2TokenValidatorResult.success(); } return OAuth2TokenValidatorResult.failure( new OAuth2Error("invalid_token", "Token not intended for this service", null)); }; OAuth2TokenValidator<Jwt> combined = new DelegatingOAuth2TokenValidator<>( JwtValidators.createDefaultWithIssuer(issuerUri), audienceValidator); decoder.setJwtValidator(combined); return decoder; }

Accessing the Token in a Controller

Once the filter chain validates the token, the full Jwt object is available in the security context. You can inject it into controller methods using @AuthenticationPrincipal:

import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @RestController public class ProfileController { @GetMapping("/api/me") public Map<String, Object> profile(@AuthenticationPrincipal Jwt jwt) { return Map.of( "subject", jwt.getSubject(), "email", jwt.getClaimAsString("email"), "name", jwt.getClaimAsString("name"), "issuedAt", jwt.getIssuedAt() ); } }
Method security with @PreAuthorize: Because @EnableMethodSecurity is on the config class, you can lock individual endpoints to specific roles without repeating requestMatchers rules everywhere:
@PreAuthorize("hasRole('ADMIN')") @DeleteMapping("/api/orders/{id}") public void deleteOrder(@PathVariable Long id) { ... }

Distributed-Systems Trade-offs

Stateless JWT validation is fast and scalable, but it comes with real trade-offs you must understand:

  • No immediate revocation: A stolen JWT remains valid until it expires. Mitigate with short expiry times (5–15 minutes) and refresh-token rotation (covered in Lesson 7).
  • Clock skew: If the Resource Server's clock drifts ahead of the Authorization Server's, tokens will appear expired. NimbusJwtDecoder allows a configurable clockSkew tolerance (default: 60 seconds).
  • JWKS caching: Spring caches the public key set and refreshes it when it encounters a signature that matches no cached key. If your Authorization Server rotates keys frequently, tune the cache TTL. Do not disable caching — fetching the JWKS on every request defeats the purpose of stateless validation.
  • Key rollover: The Authorization Server should publish new keys with a new kid (Key ID) alongside the old ones during the rotation window. Spring matches each JWT's kid header to the cached JWKS entry, so most rollover scenarios are invisible to the Resource Server.

Summary

Configuring Spring Security as an OAuth2 Resource Server takes three concrete steps: add the starter, point issuer-uri at your Authorization Server, and write a SecurityFilterChain that calls oauth2ResourceServer(...).jwt(...). The framework handles JWKS retrieval, signature verification, expiry and issuer checks, and populating the security context. Your responsibilities are audience validation, role extraction via a JwtAuthenticationConverter, and choosing sensibly short token lifetimes to limit the blast radius of a stolen token. In the next lesson you put everything together in a fully secured project.