Skip to content
Spring Boot sb auth 3 min read

OAuth2 Resource Server

A resource server is an API that protects its endpoints with access tokens issued by a separate authorization server — Google, Auth0, Keycloak, or any OIDC provider. With spring-boot-starter-oauth2-resource-server, your Spring Boot app validates incoming JWTs by fetching the issuer’s public keys and checking the signature, exp, and iss — without ever holding a signing secret. This is the standard way to secure microservice APIs and the counterpart to the client/login side.

Dependency

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

This starter pulls in Spring Security plus the JOSE/JWT decoding support. No jjwt needed — Spring decodes and validates tokens itself.

Pointing at the issuer

The cleanest configuration is a single issuer-uri. Spring fetches the provider’s OpenID configuration from {issuer}/.well-known/openid-configuration, discovers the JWK Set URL, and validates the iss claim automatically.

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://accounts.google.com

If the provider does not publish a discovery document, supply the JWK Set URI directly:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: https://login.example.com/.well-known/jwks.json
PropertyWhat it doesValidates iss?
issuer-uriDiscovers everything from /.well-known/openid-configurationYes, automatically
jwk-set-uriPoints straight at the public keysNo — add an issuer validator manually

Note: With RS256, the resource server only ever sees public keys (from the JWK Set), so it can verify signatures but never forge tokens. Spring caches and refreshes the keys, so key rotation at the IdP needs no redeploy.

Enabling it in the filter chain

Add .oauth2ResourceServer(o -> o.jwt(...)) to the SecurityFilterChain. A resource server is stateless — clients send a bearer token on every request — so disable sessions and CSRF.

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;

@Configuration
public class ResourceServerConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/api/public/**").permitAll()
                        .requestMatchers("/api/admin/**").hasAuthority("SCOPE_admin")
                        .anyRequest().authenticated())
                .oauth2ResourceServer(oauth -> oauth
                        .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthConverter())))
                .build();
    }
}

That is the whole integration: any request with a valid Authorization: Bearer <jwt> is authenticated; anything else gets 401, and a valid token lacking the required authority gets 403.

Default scope mapping

By default Spring takes the scope (or scp) claim and maps each value to an authority prefixed with SCOPE_. A token with "scope": "read admin" produces authorities SCOPE_read and SCOPE_admin — which is why the rule above uses hasAuthority("SCOPE_admin").

{ "sub": "alice", "scope": "read admin", "iss": "https://login.example.com", "exp": 1718280800 }

becomes the authorities [SCOPE_read, SCOPE_admin].

Custom authority mapping with JwtAuthenticationConverter

Many IdPs put roles in a non-standard claim (Keycloak uses realm_access.roles, others use roles or permissions). Customize the conversion with a JwtAuthenticationConverter plus a JwtGrantedAuthoritiesConverter.

import org.springframework.context.annotation.Bean;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;

import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;

@Bean
JwtAuthenticationConverter jwtAuthConverter() {
    // Keep the default SCOPE_ authorities from the "scope" claim.
    JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter();

    JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(jwt -> {
        Collection<GrantedAuthority> authorities = new HashSet<>(scopes.convert(jwt));

        // Add ROLE_* from a custom "roles" claim, e.g. ["USER","ADMIN"].
        List<String> roles = jwt.getClaimAsStringList("roles");
        if (roles != null) {
            roles.stream()
                 .map(r -> new SimpleGrantedAuthority("ROLE_" + r))
                 .forEach(authorities::add);
        }
        return authorities;
    });
    return converter;
}

Now hasRole("ADMIN") and hasAuthority("SCOPE_read") both work, and the same converter feeds method security annotations like @PreAuthorize("hasRole('ADMIN')").

Tip: For nested claims (Keycloak’s realm_access.roles), read the map with jwt.getClaimAsMap("realm_access") and pull the roles list out of it. The Keycloak page shows the exact converter.

Reading the token in a controller

Inject the validated Jwt to read claims directly:

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 ApiController {

    @GetMapping("/api/whoami")
    public String whoami(@AuthenticationPrincipal Jwt jwt) {
        return "Hello " + jwt.getSubject() + ", scopes=" + jwt.getClaimAsString("scope");
    }
}

Testing with a token

curl -s http://localhost:8080/api/whoami \
  -H "Authorization: Bearer $TOKEN"

Output:

Hello alice, scopes=read admin

A missing or expired token returns a structured 401 with a WWW-Authenticate header explaining why:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="Jwt expired at ..."

Warning: Resource server and login client are different starters for different jobs. Use spring-boot-starter-oauth2-resource-server to validate tokens on an API, and spring-boot-starter-oauth2-client to obtain them via login. A gateway-fronted API typically needs only the resource server.

Last updated June 13, 2026
Was this helpful?