Spring BootOAuth2
Spring Boot

OAuth2

OAuth2 is the industry-standard authorization framework for delegating access to resources without sharing credentials. In Spring Boot, OAuth2 appears in two distinct roles: as a Resource Server (validating access tokens on incoming API requests) and as an Authorization Server (issuing tokens). Understanding the OAuth2 flow, token types, and Spring Security's support for each role is the foundation for building secure, modern APIs.

OAuth2 Concepts and Roles

OAuth2 defines four roles. The Resource Owner is the user who owns data. The Client is the application requesting access. The Authorization Server issues tokens after authenticating the user. The Resource Server hosts the protected data and validates tokens. A Spring Boot API typically acts as a Resource Server — it validates access tokens presented by clients and enforces scope-based authorization.
Shell
# ── OAuth2 roles: ────────────────────────────────────────────────────
#
# Resource Owner  — the user (Alice) who owns the data
# Client          — the application requesting access (mobile app, SPA)
# Authorization   — Keycloak, Auth0, Okta, Google, GitHub, etc.
# Server            Issues access tokens after authenticating the user
# Resource Server — your Spring Boot API
#                   Validates access tokens on every request
#                   Enforces scope/claim-based authorization

# ── OAuth2 grant types (flows): ───────────────────────────────────────
#
# Authorization Code (+ PKCE)
#   Used by: web apps, SPAs, mobile apps
#   Flow: user redirected to AS → authenticates → AS returns code →
#         client exchanges code for tokens
#   Most secure — tokens never in browser URL
#
# Client Credentials
#   Used by: machine-to-machine (no user involved)
#   Flow: client sends client_id + client_secret → AS returns token
#   For: microservice-to-microservice, cron jobs, backend services
#
# Refresh Token
#   Used by: any flow that issues a refresh token
#   Flow: present refresh token → AS returns new access token
#   Extends sessions without re-authentication
#
# Device Code (RFC 8628)
#   Used by: TV apps, CLI tools, IoT devices
#   Flow: device shows code → user authenticates on another device
#
# (Deprecated) Implicit, Resource Owner Password Credentials
#   Do NOT use — replaced by Authorization Code + PKCE

# ── Token types: ─────────────────────────────────────────────────────
# JWT (JSON Web Token) — self-contained, validated locally by signature
# Opaque token        — random string, validated by introspection endpoint
#                       (POST /introspect to the Authorization Server)

OAuth2 Resource Server — JWT Tokens

The most common Spring Boot OAuth2 setup is a Resource Server that validates JWT access tokens. Adding spring-boot-starter-oauth2-resource-server and configuring the issuer URI is all that is required — Spring Security fetches the JWKS (public keys) automatically and validates every Bearer token.
XML
<!-- pom.xml: -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

# ── application.yml — point at the Authorization Server: ─────────────
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.example.com
          # Spring Security auto-discovers JWKS from:
          # https://auth.example.com/.well-known/openid-configuration
          # or: https://auth.example.com/.well-known/jwks.json

          # Or specify JWKS URI directly:
          # jwk-set-uri: https://auth.example.com/.well-known/jwks.json

          # Or use a local public key (for self-signed JWTs):
          # public-key-location: classpath:public.pem

# ── Security configuration — Resource Server: ────────────────────────
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class ResourceServerConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/actuator/health").permitAll()
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .jwtAuthenticationConverter(jwtAuthenticationConverter())
                )
            )
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .csrf(csrf -> csrf.disable());
        return http.build();
    }

    // ── Convert JWT claims to Spring Security authorities: ────────────
    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter authoritiesConverter =
            new JwtGrantedAuthoritiesConverter();
        authoritiesConverter.setAuthoritiesClaimName("roles");   // claim name in JWT
        authoritiesConverter.setAuthorityPrefix("ROLE_");        // adds ROLE_ prefix

        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter);
        return converter;
    }
}

Custom JWT Claims and Authorities

Authorization Servers embed claims in JWTs using different structures. Keycloak puts roles inside realm_access.roles; Auth0 uses custom namespace claims; AWS Cognito uses cognito:groups. A custom JwtAuthenticationConverter maps these to Spring Security GrantedAuthority objects.
Java
// ── Custom converter — handles different JWT claim structures: ─────────
@Configuration
public class JwtConfig {

    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(this::extractAuthorities);
        return converter;
    }

    private Collection<GrantedAuthority> extractAuthorities(Jwt jwt) {
        List<GrantedAuthority> authorities = new ArrayList<>();

        // ── Keycloak — realm_access.roles: ───────────────────────────
        Map<String, Object> realmAccess = jwt.getClaim("realm_access");
        if (realmAccess != null) {
            List<String> roles = (List<String>) realmAccess.get("roles");
            if (roles != null) {
                roles.stream()
                    .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
                    .forEach(authorities::add);
            }
        }

        // ── Keycloak — resource_access.{client-id}.roles: ────────────
        Map<String, Object> resourceAccess = jwt.getClaim("resource_access");
        if (resourceAccess != null) {
            Map<String, Object> clientAccess =
                (Map<String, Object>) resourceAccess.get("myapp");
            if (clientAccess != null) {
                List<String> clientRoles = (List<String>) clientAccess.get("roles");
                if (clientRoles != null) {
                    clientRoles.stream()
                        .map(r -> new SimpleGrantedAuthority("ROLE_" + r))
                        .forEach(authorities::add);
                }
            }
        }

        // ── Standard OAuth2 scopes — from "scope" or "scp" claim: ────
        String scope = jwt.getClaimAsString("scope");
        if (scope != null) {
            Arrays.stream(scope.split(" "))
                .map(s -> new SimpleGrantedAuthority("SCOPE_" + s))
                .forEach(authorities::add);
        }

        // ── Auth0 — custom namespace claims: ─────────────────────────
        List<String> permissions =
            jwt.getClaimAsStringList("https://myapp.com/permissions");
        if (permissions != null) {
            permissions.stream()
                .map(SimpleGrantedAuthority::new)
                .forEach(authorities::add);
        }

        return authorities;
    }
}

// ── Accessing JWT claims in a controller: ─────────────────────────────
@RestController
@RequestMapping("/api/me")
public class MeController {

    @GetMapping
    public Map<String, Object> getMe(
            @AuthenticationPrincipal Jwt jwt) {
        return Map.of(
            "subject",   jwt.getSubject(),
            "email",     jwt.getClaimAsString("email"),
            "name",      jwt.getClaimAsString("name"),
            "roles",     jwt.getClaimAsStringList("roles"),
            "expiresAt", jwt.getExpiresAt()
        );
    }
}

OAuth2 Resource Server — Opaque Tokens

Opaque tokens are random strings with no embedded claims. The Resource Server validates them by calling the Authorization Server's introspection endpoint on every request. This is slower than JWT validation but allows immediate token revocation.
yaml
# ── application.yml — opaque token configuration: ───────────────────
spring:
  security:
    oauth2:
      resourceserver:
        opaquetoken:
          introspection-uri: https://auth.example.com/oauth2/introspect
          client-id: myapp-resource-server
          client-secret: ${INTROSPECTION_CLIENT_SECRET}

// ── Security configuration — opaque token Resource Server: ───────────
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/api/public/**").permitAll()
            .anyRequest().authenticated()
        )
        .oauth2ResourceServer(oauth2 -> oauth2
            .opaqueToken(opaque -> opaque
                .introspectionUri(
                    "https://auth.example.com/oauth2/introspect")
                .introspectionClientCredentials(
                    "myapp-resource-server",
                    introspectionSecret)
                .authenticationConverter(
                    new OpaqueTokenAuthenticationConverter() {
                        @Override
                        public AbstractAuthenticationToken convert(
                                String introspectionResponse,
                                OAuth2AuthenticatedPrincipal principal) {
                            // Map introspection response to authorities:
                            Collection<GrantedAuthority> authorities =
                                principal.getAttributes()
                                    .getOrDefault("roles", List.of()) instanceof
                                        List<?> roles ?
                                    ((List<String>) roles).stream()
                                        .map(r -> new SimpleGrantedAuthority(
                                            "ROLE_" + r))
                                        .collect(Collectors.toList()) :
                                    List.of();
                            return new BearerTokenAuthentication(
                                principal,
                                new OAuth2AccessToken(
                                    OAuth2AccessToken.TokenType.BEARER,
                                    introspectionResponse, null, null),
                                authorities);
                        }
                    })
            )
        )
        .sessionManagement(session -> session
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .csrf(csrf -> csrf.disable());
    return http.build();
}

// ── Accessing claims from opaque token: ───────────────────────────────
@GetMapping("/me")
public Map<String, Object> getMe(
        @AuthenticationPrincipal OAuth2AuthenticatedPrincipal principal) {
    return Map.of(
        "subject",  principal.getName(),
        "email",    principal.getAttribute("email"),
        "scopes",   principal.getAttribute("scope")
    );
}

Scope-Based Authorization

OAuth2 scopes represent what a client is permitted to do. After configuring the Resource Server, scopes become SCOPE_-prefixed GrantedAuthority objects. Use hasAuthority('SCOPE_read') in URL rules and @PreAuthorize to enforce scope requirements.
Java
// ── URL-based scope authorization: ───────────────────────────────────
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests(auth -> auth
        // Scope-based rules — JWT "scope" claim maps to SCOPE_* authorities:
        .requestMatchers(HttpMethod.GET, "/api/users/**")
            .hasAuthority("SCOPE_users:read")
        .requestMatchers(HttpMethod.POST, "/api/users/**")
            .hasAuthority("SCOPE_users:write")
        .requestMatchers(HttpMethod.DELETE, "/api/users/**")
            .hasAuthority("SCOPE_users:delete")

        // Role + scope combined:
        .requestMatchers("/api/admin/**")
            .access(new WebExpressionAuthorizationManager(
                "hasRole('ADMIN') and hasAuthority('SCOPE_admin')"))

        .anyRequest().authenticated()
    );
    return http.build();
}

// ── Method-level scope authorization: ────────────────────────────────
@Service
public class UserService {

    @PreAuthorize("hasAuthority('SCOPE_users:read')")
    public List<UserResponse> findAll() { ... }

    @PreAuthorize("hasAuthority('SCOPE_users:write')")
    public UserResponse create(CreateUserRequest request) { ... }

    @PreAuthorize("hasAuthority('SCOPE_users:delete') and hasRole('ADMIN')")
    public void delete(Long id) { ... }

    // Scope from machine-to-machine (Client Credentials flow):
    @PreAuthorize("hasAuthority('SCOPE_internal:read')")
    public List<UserResponse> findAllForSync() { ... }
}

// ── Client Credentials flow — machine-to-machine authorization: ───────
// Service A (client) → obtains token with scope "internal:read"
// Service B (resource server) → validates token, checks SCOPE_internal:read

// Service A configuration (OAuth2 client calling Service B):
spring:
  security:
    oauth2:
      client:
        registration:
          service-b:
            client-id: service-a
            client-secret: ${SERVICE_A_SECRET}
            authorization-grant-type: client_credentials
            scope: internal:read
        provider:
          service-b:
            token-uri: https://auth.example.com/oauth2/token

OAuth2 Client — Calling Protected APIs

A Spring Boot service can act as an OAuth2 client, obtaining tokens automatically and injecting them into outbound HTTP calls. The OAuth2AuthorizedClientManager handles token acquisition, caching, and refresh transparently.
XML
<!-- pom.xml: -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

# ── application.yml — register the client credentials client: ─────────
spring:
  security:
    oauth2:
      client:
        registration:
          inventory-service:
            client-id: myapp
            client-secret: ${CLIENT_SECRET}
            authorization-grant-type: client_credentials
            scope: inventory:read,inventory:write
        provider:
          inventory-service:
            token-uri: https://auth.example.com/oauth2/token

// ── Configure WebClient with automatic token injection: ───────────────
@Configuration
public class WebClientConfig {

    @Bean
    public OAuth2AuthorizedClientManager authorizedClientManager(
            ClientRegistrationRepository registrations,
            OAuth2AuthorizedClientService clientService) {
        OAuth2AuthorizedClientProvider provider =
            OAuth2AuthorizedClientProviderBuilder.builder()
                .clientCredentials()
                .refreshToken()
                .build();

        AuthorizedClientServiceOAuth2AuthorizedClientManager manager =
            new AuthorizedClientServiceOAuth2AuthorizedClientManager(
                registrations, clientService);
        manager.setAuthorizedClientProvider(provider);
        return manager;
    }

    @Bean
    public WebClient inventoryWebClient(
            OAuth2AuthorizedClientManager authorizedClientManager) {
        ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2 =
            new ServletOAuth2AuthorizedClientExchangeFilterFunction(
                authorizedClientManager);
        oauth2.setDefaultClientRegistrationId("inventory-service");

        return WebClient.builder()
            .baseUrl("https://inventory-service.internal")
            .apply(oauth2.oauth2Configuration())
            .build();
    }
}

// ── Use the WebClient — token injected automatically: ─────────────────
@Service
@RequiredArgsConstructor
public class InventoryClient {

    private final WebClient inventoryWebClient;

    public InventoryResponse getStock(Long productId) {
        return inventoryWebClient.get()
            .uri("/api/inventory/{id}", productId)
            // Bearer token automatically added from OAuth2 client manager
            .retrieve()
            .bodyToMono(InventoryResponse.class)
            .block();
    }
}