Spring Boot

OAuth2

How OAuth2 works in a Spring Boot 3 application: the four roles, the grant types worth using, and the two roles you will actually build. Covers a Resource Server that validates JWT or opaque access tokens, mapping Keycloak, Auth0 and Cognito claims to roles and scopes, issuer and audience validation, scope-based authorization, calling protected APIs with the client credentials flow, and testing secured endpoints.

OAuth2 Concepts and Roles

OAuth2 defines four roles. - Resource Owner: the user who owns the data. - Client: the application requesting access on the user's behalf (a mobile app, a SPA, another service). - Authorization Server: authenticates the user and issues tokens. Keycloak, Auth0, Okta, Cognito, Google and Microsoft Entra ID all play this role. - Resource Server: hosts the protected data and validates the access token on every request. A Spring Boot API is almost always a Resource Server: it never sees the user's password, it only receives an access token and decides what that token allows. A Spring Boot service that calls another protected API is an OAuth2 Client. Running your own Authorization Server is a separate, much larger job (see the last section on when that makes sense). One distinction prevents many bugs: OAuth2 is about authorization (what a token may do). Logging a user in is the job of OpenID Connect, which adds an ID token on top of OAuth2. Send the access token to your API, never the ID token.

Grant Types and Token Types

Grant types (flows): - Authorization Code with PKCE: the default for web apps, SPAs and mobile apps. The user signs in at the Authorization Server, which returns a short-lived code that the client exchanges for tokens, so tokens never travel in the browser URL. Use this whenever a user is involved. - Client Credentials: machine-to-machine, no user. The client sends its client id and secret and gets a token. Use it for microservice calls, scheduled jobs and backend integrations. - Refresh Token: exchanges a refresh token for a new access token so sessions last without signing in again. - Device Code (RFC 8628): for TVs, CLIs and IoT devices that cannot show a browser. - Implicit and Resource Owner Password Credentials: deprecated and removed from OAuth 2.1. Do not use them. Authorization Code with PKCE replaces both. Token types: - JWT: self-contained and validated locally by checking the signature against the Authorization Server's public keys. Fast, but it cannot be revoked before it expires, so keep the lifetime short. - Opaque: a random string. The Resource Server asks the Authorization Server's introspection endpoint whether it is valid. Slower, since there is a network call, but revocation takes effect immediately.

Resource Server — JWT Setup

Add spring-boot-starter-oauth2-resource-server and point it at the Authorization Server with issuer-uri. Spring Security reads the issuer's metadata (the .well-known/openid-configuration endpoint, or the OAuth 2.0 equivalent), finds the JWKS URL, downloads the public keys, and verifies the signature, expiry and issuer of every Bearer token. Use jwk-set-uri when you know the key URL directly, or public-key-location when you hold a static public key and sign tokens yourself. Note what issuer-uri does not check by default: the audience. A token issued for a different API by the same Authorization Server will pass. The audience section below closes that gap.
yaml
# ── application.yml ────────────────────────────────────────────────────
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.example.com
          # Alternatives (use one approach):
          # jwk-set-uri: https://auth.example.com/.well-known/jwks.json
          # public-key-location: classpath:public.pem

app:
  security:
    audience: my-api          # expected "aud" claim (used in the audience section)
    client-id: myapp          # Keycloak client id, used for client roles

Resource Server — Security Configuration

The filter chain turns on JWT validation with oauth2ResourceServer, makes the API stateless, and requires authentication for everything except the public paths. Disabling CSRF is acceptable here because the API authenticates with an Authorization header, not a cookie. The converter bean that maps token claims to authorities is defined in the next section and injected here.
Java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class ResourceServerConfig {

    @Bean
    public SecurityFilterChain filterChain(
            HttpSecurity http,
            JwtAuthenticationConverter jwtAuthenticationConverter) 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());   // bearer tokens only, no cookies
        return http.build();
    }
}

Mapping JWT Claims to Roles and Scopes

Spring Security turns the token's scope claim into authorities prefixed with SCOPE_ by default. Roles are provider-specific: Keycloak puts them in realm_access.roles and resource_access.<client>.roles, Cognito uses cognito:groups, and Auth0 uses a permissions claim or a namespaced custom claim. Map them yourself in one JwtAuthenticationConverter bean. Two mistakes are common here. First, calling setAuthoritiesClaimName("roles") on a JwtGrantedAuthoritiesConverter replaces the scope handling, so every SCOPE_ authority disappears and scope rules start returning 403. The converter below keeps the default scope converter (which reads both scope and scp) and adds roles on top. Second, defining a second bean named jwtAuthenticationConverter in another class fails at startup, because bean overriding is disabled by default. Keep exactly one. Note that hasRole('ADMIN') matches the authority ROLE_ADMIN, and role names are case-sensitive: a Keycloak role named admin will not match ADMIN.
Java
@Configuration
public class JwtConfig {

    @Value("${app.security.client-id}")
    private String clientId;

    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        // Default converter: "scope" or "scp" claim → SCOPE_* authorities
        JwtGrantedAuthoritiesConverter scopeConverter = new JwtGrantedAuthoritiesConverter();

        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(jwt -> {
            Set<GrantedAuthority> authorities = new LinkedHashSet<>(scopeConverter.convert(jwt));
            authorities.addAll(extractRoles(jwt));
            return authorities;
        });
        return converter;
    }

    private Set<GrantedAuthority> extractRoles(Jwt jwt) {
        Set<String> roles = new LinkedHashSet<>();

        // Keycloak realm roles: realm_access.roles
        roles.addAll(nestedList(jwt.getClaim("realm_access"), "roles"));

        // Keycloak client roles: resource_access.<client-id>.roles
        Object resourceAccess = jwt.getClaim("resource_access");
        if (resourceAccess instanceof Map<?, ?> clients) {
            roles.addAll(nestedList(clients.get(clientId), "roles"));
        }

        // AWS Cognito groups
        roles.addAll(asStringList(jwt.getClaim("cognito:groups")));

        // Generic top-level "roles" claim. For Auth0 custom claims use your
        // namespaced name, for example "https://myapp.com/roles".
        roles.addAll(asStringList(jwt.getClaim("roles")));

        Set<GrantedAuthority> authorities = new LinkedHashSet<>();
        roles.stream()
            .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
            .forEach(authorities::add);

        // Auth0 RBAC permissions are used as-is, e.g. "read:users"
        asStringList(jwt.getClaim("permissions")).stream()
            .map(SimpleGrantedAuthority::new)
            .forEach(authorities::add);

        return authorities;
    }

    private static List<String> nestedList(Object container, String key) {
        return container instanceof Map<?, ?> map ? asStringList(map.get(key)) : List.of();
    }

    private static List<String> asStringList(Object value) {
        if (value instanceof Collection<?> items) {
            return items.stream().filter(Objects::nonNull).map(Object::toString).toList();
        }
        return List.of();
    }
}

// ── Reading the authenticated user in a controller ────────────────────
public record MeResponse(String subject, String email, String name,
                         List<String> authorities, Instant expiresAt) {}

@RestController
@RequestMapping("/api/me")
public class MeController {

    @GetMapping
    public MeResponse getMe(@AuthenticationPrincipal Jwt jwt,
                            Authentication authentication) {
        // A record is used on purpose: Map.of throws a NullPointerException
        // when a claim such as email is missing from the token.
        return new MeResponse(
            jwt.getSubject(),
            jwt.getClaimAsString("email"),
            jwt.getClaimAsString("name"),
            authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .sorted()
                .toList(),
            jwt.getExpiresAt());
    }
}

Validating the Audience

The default validators check the signature, the expiry and the issuer. They do not check that the token was meant for your API. If the same Authorization Server issues tokens for several APIs, a token for one of them is accepted by all of them. Add an audience validator by defining your own JwtDecoder. This decoder contacts the issuer at startup, so the Authorization Server must be reachable when the application boots.
Java
@Configuration
public class JwtDecoderConfig {

    @Bean
    public JwtDecoder jwtDecoder(
            @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") String issuer,
            @Value("${app.security.audience}") String audience) {

        NimbusJwtDecoder decoder = JwtDecoders.fromIssuerLocation(issuer);

        OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuer);
        OAuth2TokenValidator<Jwt> withAudience = new JwtClaimValidator<List<String>>(
            JwtClaimNames.AUD,
            aud -> aud != null && aud.contains(audience));

        decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(withIssuer, withAudience));
        return decoder;
    }
}

Resource Server — Opaque Tokens

Opaque tokens carry no claims. The Resource Server sends each token to the Authorization Server's introspection endpoint and receives a JSON response with active, scope, sub and any other attributes. This costs a network call per request, so cache the result for a few seconds if throughput matters, accepting that revocation is then delayed by that window. In exchange you get immediate revocation without waiting for expiry. Configure the endpoint and the credentials the Resource Server uses to call it. Keep the secret in an environment variable.
yaml
# ── application.yml ────────────────────────────────────────────────────
spring:
  security:
    oauth2:
      resourceserver:
        opaquetoken:
          introspection-uri: https://auth.example.com/oauth2/introspect
          client-id: myapp-resource-server
          client-secret: ${INTROSPECTION_CLIENT_SECRET}

Opaque Tokens — Adding Roles

The default introspector already converts the scope attribute into SCOPE_ authorities. To add roles, wrap it in your own OpaqueTokenIntrospector instead of replacing the whole authentication conversion, so scopes are kept and only your additions are layered on. Spring Boot picks up an OpaqueTokenIntrospector bean automatically, so the filter chain only needs to enable opaque token support. The builder shown requires a recent Spring Security 6.x release; on older versions use the SpringOpaqueTokenIntrospector constructor with the same three arguments.
Java
@Component
public class RoleAwareOpaqueTokenIntrospector implements OpaqueTokenIntrospector {

    private final OpaqueTokenIntrospector delegate;

    public RoleAwareOpaqueTokenIntrospector(
            @Value("${spring.security.oauth2.resourceserver.opaquetoken.introspection-uri}") String uri,
            @Value("${spring.security.oauth2.resourceserver.opaquetoken.client-id}") String clientId,
            @Value("${spring.security.oauth2.resourceserver.opaquetoken.client-secret}") String clientSecret) {
        this.delegate = SpringOpaqueTokenIntrospector.withIntrospectionUri(uri)
            .clientId(clientId)
            .clientSecret(clientSecret)
            .build();
    }

    @Override
    public OAuth2AuthenticatedPrincipal introspect(String token) {
        OAuth2AuthenticatedPrincipal principal = delegate.introspect(token);  // SCOPE_ already added

        Set<GrantedAuthority> authorities = new LinkedHashSet<>(principal.getAuthorities());
        Object roles = principal.getAttribute("roles");
        if (roles instanceof Collection<?> items) {
            items.forEach(role -> authorities.add(new SimpleGrantedAuthority("ROLE_" + role)));
        }
        return new DefaultOAuth2AuthenticatedPrincipal(
            principal.getName(), principal.getAttributes(), authorities);
    }
}

// ── Filter chain: just enable opaque token support ────────────────────
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/api/public/**").permitAll()
            .anyRequest().authenticated())
        .oauth2ResourceServer(oauth2 -> oauth2
            .opaqueToken(Customizer.withDefaults()))
        .sessionManagement(session -> session
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .csrf(csrf -> csrf.disable());
    return http.build();
}

// ── Reading the principal ─────────────────────────────────────────────
@GetMapping("/me")
public MeResponse getMe(@AuthenticationPrincipal OAuth2AuthenticatedPrincipal principal) {
    // getAttribute returns null for missing attributes, so avoid Map.of here too
    return new MeResponse(
        principal.getName(),
        principal.getAttribute("email"),
        principal.getAttribute("name"),
        principal.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority).sorted().toList(),
        null);
}

Scope-Based Authorization

Scopes describe what a client is allowed to do. Once the converter above is in place they appear as authorities prefixed with SCOPE_, so use hasAuthority('SCOPE_users:read') in URL rules and @PreAuthorize. List the more specific URL rules before the general ones, because the first match wins. Scopes and roles answer different questions. A scope limits what the client application may do; a role says what the user may do. For a user-facing call you often want both. A token from the client credentials flow has no user behind it, so it carries scopes but no user roles. A rule such as hasRole('ADMIN') will always reject it.
Java
// ── URL-based scope authorization ────────────────────────────────────
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests(auth -> auth
        .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 and scope combined
        .requestMatchers("/api/admin/**")
            .access(new WebExpressionAuthorizationManager(
                "hasRole('ADMIN') and hasAuthority('SCOPE_admin')"))

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

// ── Method-level authorization (needs @EnableMethodSecurity) ─────────
@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) { ... }

    // Machine-to-machine token (client credentials flow): scope only, no user roles
    @PreAuthorize("hasAuthority('SCOPE_internal:read')")
    public List<UserResponse> findAllForSync() { ... }
}

Machine-to-Machine: Client Credentials Registration

When one service calls another, the caller is an OAuth2 Client using the client credentials flow. It obtains a token with the scopes it needs, and the called service (a Resource Server) checks for them. Register the calling service in its own configuration. Add spring-boot-starter-oauth2-client for the client support, and spring-boot-starter-webflux if you want to use WebClient, which works fine alongside spring-boot-starter-web.
yaml
# ── Service A's application.yml (the caller) ───────────────────────────
spring:
  security:
    oauth2:
      client:
        registration:
          inventory-service:
            client-id: service-a
            client-secret: ${SERVICE_A_SECRET}
            authorization-grant-type: client_credentials
            scope: inventory:read,inventory:write
        provider:
          inventory-service:
            token-uri: https://auth.example.com/oauth2/token

Calling Protected APIs with WebClient

Spring Security's WebClient filter fetches a token from the Authorization Server, caches it until it expires, and adds it as a Bearer header on every outbound call. A client credentials token has no refresh token, so the manager only needs the client credentials provider. Because this runs outside an HTTP request context (scheduled jobs, async calls), use AuthorizedClientServiceOAuth2AuthorizedClientManager, not the request-scoped variant. If you prefer RestClient to WebClient, recent Spring Security versions provide an equivalent interceptor, so check the reference for your version.
Java
@Configuration
public class WebClientConfig {

    @Bean
    public OAuth2AuthorizedClientManager authorizedClientManager(
            ClientRegistrationRepository registrations,
            OAuth2AuthorizedClientService clientService) {

        OAuth2AuthorizedClientProvider provider =
            OAuth2AuthorizedClientProviderBuilder.builder()
                .clientCredentials()
                .build();

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

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

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

// ── Use the WebClient — the token is added automatically ──────────────
@Service
@RequiredArgsConstructor
public class InventoryClient {

    private final WebClient inventoryWebClient;

    public InventoryResponse getStock(Long productId) {
        return inventoryWebClient.get()
            .uri("/api/inventory/{id}", productId)
            .retrieve()
            .bodyToMono(InventoryResponse.class)
            .block();   // fine in a servlet app; return the Mono in a reactive one
    }
}

Testing Secured Endpoints

You do not need a real Authorization Server to test security. Spring Security's test support provides a jwt() request post-processor that injects an authenticated JWT into MockMvc and skips decoding. Set the authorities you want directly, which also bypasses your converter, so test the converter separately if it contains real logic. The test context still needs a JwtDecoder bean to exist, so mock one. Use @MockitoBean on Spring Boot 3.4 and later, and @MockBean on earlier versions. The static imports come from SecurityMockMvcRequestPostProcessors.jwt and the MockMvc request and result builders.
Java
@WebMvcTest(UserController.class)
@Import({ResourceServerConfig.class, JwtConfig.class})
class UserControllerSecurityTest {

    @Autowired private MockMvc mockMvc;
    @MockitoBean private UserService userService;
    @MockitoBean private JwtDecoder jwtDecoder;   // required by the resource server config

    @Test
    void withoutToken_returns401() throws Exception {
        mockMvc.perform(get("/api/users"))
            .andExpect(status().isUnauthorized());
    }

    @Test
    void withReadScope_returns200() throws Exception {
        mockMvc.perform(get("/api/users")
                .with(jwt().authorities(new SimpleGrantedAuthority("SCOPE_users:read"))))
            .andExpect(status().isOk());
    }

    @Test
    void withWrongScope_returns403() throws Exception {
        mockMvc.perform(get("/api/users")
                .with(jwt().authorities(new SimpleGrantedAuthority("SCOPE_other"))))
            .andExpect(status().isForbidden());
    }
}

When to Run Your Own Authorization Server

Most teams should not. Issuing tokens safely means handling consent, key rotation, user storage, MFA, account recovery and standards compliance, and a managed provider or Keycloak already does all of it. Run your own only when you need full control of the token format and flows, when regulation requires it, or when you are building an identity product. In that case Spring Authorization Server is the supported Spring project, available through its own starter in Spring Boot 3.1 and later, and its reference documentation is the right starting point. For everything else, keep your Spring Boot services as Resource Servers and Clients and point them at an existing issuer.

Common Mistakes

1. Calling setAuthoritiesClaimName on the default converter. It silently drops every SCOPE_ authority. 2. Defining two jwtAuthenticationConverter beans. The application fails at startup. 3. Skipping audience validation. A token issued for a different API by the same issuer is accepted. 4. Mixing up hasRole and hasAuthority. hasRole('ADMIN') needs the authority ROLE_ADMIN, and scopes need SCOPE_ in hasAuthority. 5. Expecting roles on client credentials tokens. There is no user, so only scopes are present. 6. Using Map.of for claims. A missing claim throws a NullPointerException. Use a record or a null-tolerant map. 7. Sending the ID token to the API. Use the access token. 8. Hardcoding the Keycloak client id or the audience. Read both from configuration. 9. Long-lived JWT access tokens. They cannot be revoked, so keep them to minutes and use refresh tokens, or switch to opaque tokens if instant revocation matters. 10. Using the implicit or password grant. Use Authorization Code with PKCE. 11. Committing client secrets. Read them from environment variables or a secrets manager.

FAQ

What is the difference between OAuth2 and JWT? OAuth2 is a framework for delegated authorization. JWT is a token format. OAuth2 access tokens are often JWTs, but they can also be opaque strings. Should my Spring Boot API be a Resource Server or a Client? A Resource Server if it receives tokens and protects its own endpoints. A Client if it calls other protected APIs. Many services are both. Which flow should a single-page app use? Authorization Code with PKCE. Do not use the implicit flow, and do not embed a client secret in browser code. JWT or opaque tokens? JWTs validate locally and scale well but cannot be revoked early. Opaque tokens revoke instantly but need an introspection call, which you can cache briefly. Pick JWTs unless you have a hard revocation requirement. Why do I get 403 even though my token is valid? The token authenticated, but its authorities do not match the rule. Log the authorities, then check whether scopes carry the SCOPE_ prefix, roles carry ROLE_, and the converter you defined is the one in use. Why do I get 401 for a valid token? Common causes are a wrong issuer-uri, an audience mismatch, an expired token, or clock skew between servers. How do I test endpoints without an Authorization Server? Use the jwt() post-processor from Spring Security's test support with MockMvc, as shown above.