Published on

OAuth 2.0 and OIDC for Spring Boot APIs: Token Validation, Authorization, and Service Calls

Authors
  • avatar
    Name
    Maria
    Twitter

An API is not secure merely because it rejects requests without a JWT. A resource server must decide which authorization server it trusts, whether the token was issued for this API, which algorithm and key are acceptable, how token claims become authorities, and whether a particular identity may perform the requested action.

Those decisions become harder in a microservice system because several identities can be present:

  • the end user who initiated a request;
  • the browser or mobile client acting for that user;
  • a gateway;
  • the calling workload;
  • the downstream service receiving a propagated or exchanged token.

This guide builds a Spring Boot 4.1 resource server around explicit trust boundaries. It uses OAuth 2.0 access tokens for API authorization and keeps OpenID Connect identity tokens in their proper role. It also explains why “OAuth 2.1” should not be treated as a finished magic upgrade: as of July 2026, the OAuth 2.1 document is still an IETF Internet-Draft, while the security practices teams should apply today are published in RFC 9700.

TL;DR OAuth protects delegated access; OIDC adds authentication and identity claims. A Spring Boot API is a resource server and should receive an access token, not an ID token. Validate signature, issuer, time claims, and audience before evaluating permissions. Map scopes or roles explicitly, and enforce object-level rules in the application. Use Authorization Code with PKCE for user-facing public clients and Client Credentials for workload identity. Do not forward one powerful bearer token through every service by default.

Separate Four Responsibilities

Many insecure implementations start by mixing distinct components.

ComponentResponsibilityTypical artifact
Authorization serverAuthenticates principals, obtains consent where required, and issues tokensAccess token, refresh token, ID token
OIDC clientSigns a user into an application and maintains an application sessionID token and session
OAuth clientObtains an access token to call an APIAccess token
Resource serverValidates the access token and protects API resourcesAuthorization decision

OAuth 2.0 is an authorization framework. OpenID Connect is an identity layer built on top of OAuth 2.0. OIDC introduces the ID token, UserInfo endpoint, discovery metadata, and authentication semantics.

An ID token tells the client about an authentication event. It is intended for that client and its audience normally identifies the client. It is not a general API credential. The API should validate an access token whose audience identifies the API.

That distinction prevents a common vulnerability: accepting a correctly signed token that was issued for a different recipient.

Draw the Trust Boundary Before Writing Configuration

Consider an order system:

Browser -> Web application -> Order API -> Inventory API
                     |
                     -> Authorization server

Answer these questions:

  1. Which authorization-server issuer may mint tokens for the Order API?
  2. What audience value identifies the Order API?
  3. Which scopes allow order reads and writes?
  4. Does Inventory need the user's identity, the Order service's workload identity, or both?
  5. Who owns object-level checks such as “the caller may read this order”?
  6. What happens when keys rotate or the authorization server is temporarily unavailable?
  7. Which token fields may be logged, and which must be redacted?

Network location is not an authorization rule. A request that came through a gateway still needs a valid identity and permission at the service that owns the resource.

Configure a Spring Boot Resource Server

Add the resource-server and security starters:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

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

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <scope>test</scope>
</dependency>

Configure the trusted issuer. Keep environment-specific values outside the source tree:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: ${ORDER_API_ISSUER}

security:
  audience: order-api

Issuer-based discovery lets Spring Security locate authorization-server metadata and its JWK Set. The decoder verifies the signature with an allowed key, validates standard time claims, and validates the issuer. Audience validation is an application trust decision, so make it explicit.

import java.util.List;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimValidator;
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;

@Configuration(proxyBeanMethods = false)
class JwtConfiguration {

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

        NimbusJwtDecoder decoder =
            (NimbusJwtDecoder) JwtDecoders.fromIssuerLocation(issuer);

        OAuth2TokenValidator<Jwt> issuerAndTime =
            JwtValidators.createDefaultWithIssuer(issuer);

        OAuth2TokenValidator<Jwt> audience =
            new JwtClaimValidator<List<String>>(
                "aud",
                values -> values != null && values.contains(requiredAudience)
            );

        decoder.setJwtValidator(
            new DelegatingOAuth2TokenValidator<>(issuerAndTime, audience)
        );

        return decoder;
    }
}

Do not decode a JWT with a general JSON library and then trust its claims. Decoding proves only that the string is parseable. Validation must bind the token to a trusted issuer, key, algorithm, recipient, and validity window.

Spring Security's JWT resource-server reference documents discovery, JWK selection, validators, and authority mapping.

Define Authorization as a Contract

Spring maps the token's scope or scp values to authorities prefixed with SCOPE_. Configure endpoint rules using those authorities:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration(proxyBeanMethods = false)
@EnableMethodSecurity
class SecurityConfiguration {

    @Bean
    SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/actuator/health").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/orders/**")
                    .hasAuthority("SCOPE_orders.read")
                .requestMatchers(HttpMethod.POST, "/api/orders")
                    .hasAuthority("SCOPE_orders.write")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> {}))
            .build();
    }
}

Scopes should describe delegated API capabilities, not mirror every UI button. Use names such as orders.read, orders.write, and orders.cancel. Keep them stable enough for clients to depend on and narrow enough that a leaked token does not grant unrelated power.

Endpoint rules are only the first layer. A caller with orders.read may not be entitled to every order. Put object-level authorization close to the domain operation:

@PreAuthorize("""
    hasAuthority('SCOPE_orders.read')
    and @orderAccess.canRead(#orderId, authentication)
    """)
public OrderView findOrder(UUID orderId) {
    return orderQuery.findRequired(orderId);
}

The orderAccess component can compare tenant, account, ownership, delegation, or policy data. Do not accept a tenant identifier from an untrusted request header just because a gateway usually supplies it.

Roles, Scopes, and Claims Are Different Things

Identity providers encode roles differently. One may use roles, another realm_access.roles, and another custom namespaced claims. Avoid scattering provider-specific JSON paths throughout controllers.

If a provider claim must become an authority, isolate that mapping:

@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
    JwtGrantedAuthoritiesConverter scopes =
        new JwtGrantedAuthoritiesConverter();

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

        List<String> roles = jwt.getClaimAsStringList("roles");
        if (roles != null) {
            roles.stream()
                .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
                .forEach(authorities::add);
        }

        return authorities;
    });
    return converter;
}

Wire the converter into the JWT DSL if needed. Keep the authorization contract in your service even if the identity provider changes.

Prefer a stable subject identifier such as sub for technical identity. Email addresses and display names can change and are often unsuitable as database keys.

Choose the Correct User Flow

For a browser or mobile application, use Authorization Code with PKCE. PKCE binds the authorization request to the client that later exchanges the code. Use an exact allowlist of redirect URIs and the authorization server's state and OIDC nonce protections as appropriate.

Do not use:

  • the Implicit grant;
  • Resource Owner Password Credentials;
  • wildcard redirect URIs;
  • access tokens in URL query parameters;
  • a client secret embedded in a browser bundle or mobile binary.

OAuth 2.1 consolidates these modern practices, but it remains a draft as of this article's last update. The normative published security baseline is OAuth 2.0 Security Best Current Practice, RFC 9700.

For a server-rendered web application, consider a backend-for-frontend pattern. The backend completes the OAuth flow, keeps tokens away from browser JavaScript, and exposes a session cookie with appropriate HttpOnly, Secure, SameSite, and CSRF controls. This is a different component from the API resource server.

Choose the Correct Service Identity

A scheduled worker or backend service acting on its own behalf can use Client Credentials. Give each workload a separate client identity and narrow audience and scopes:

spring:
  security:
    oauth2:
      client:
        registration:
          inventory-client:
            provider: internal-idp
            authorization-grant-type: client_credentials
            client-id: ${INVENTORY_CLIENT_ID}
            client-secret: ${INVENTORY_CLIENT_SECRET}
            scope: inventory.reserve
        provider:
          internal-idp:
            token-uri: ${INTERNAL_TOKEN_URI}

A client secret is a credential. Store it in a secret manager, rotate it, and prefer stronger client authentication such as private-key JWT or mutual TLS when the authorization server and risk model support it.

If the downstream service must act for the user, blindly forwarding the original bearer token has costs:

  • the token may not be intended for the downstream audience;
  • every service receives all of its claims and powers;
  • long call chains increase exposure;
  • revocation and audit semantics become unclear.

Consider token exchange or a narrowly scoped downstream token when supported. Otherwise, document the propagation boundary and make every downstream service validate the token independently.

JWT or Opaque Access Token?

JWT access tokens allow local validation and can keep API availability independent of a per-request authorization-server call. They also remain valid until expiry unless the architecture adds revocation or another state check.

Opaque tokens are validated through introspection. They expose fewer claims to the client and make centralized revocation decisions easier, but add network latency and a dependency on the introspection service. Caching can reduce that dependency but changes revocation timing.

Choose based on:

  • revocation requirements;
  • token lifetime;
  • acceptable identity-provider dependency;
  • claim privacy;
  • service count and request rate;
  • operational ownership of key rotation and caching.

Do not put secrets, personal data, or large authorization graphs in a JWT merely because it is signed. Bearer tokens routinely pass through client memory, proxies, and service infrastructure.

Key Rotation and Availability

JWT validation depends on JWKs. A sound design accounts for:

  • a stable issuer identifier;
  • overlapping old and new signing keys during rotation;
  • cache headers and refresh behavior;
  • unknown kid handling;
  • algorithm allowlists;
  • clock skew kept intentionally small;
  • authorization-server discovery availability during startup.

If startup must not depend on provider discovery, configure the JWK Set URI as well as the issuer and continue validating iss. This removes one discovery call from startup; it does not remove the need to obtain and refresh keys.

Never accept the algorithm named by an untrusted token without constraining what the decoder permits. Never use a symmetric key across many resource servers unless every holder is intentionally trusted to mint tokens too.

Return Useful but Safe Errors

Authentication and authorization failures have different meanings:

  • 401 Unauthorized: the request lacks acceptable authentication;
  • 403 Forbidden: authentication succeeded but permission is insufficient;
  • 404 Not Found: sometimes appropriate to avoid exposing object existence after policy evaluation.

Do not return decoder exceptions, signing details, token contents, or policy internals to clients. Log a correlation identifier, endpoint, decision category, issuer, client or subject identifier where permitted, and the policy version. Never log the raw access token.

Test the Security Boundary

Security tests should prove rejection, not only success:

@WebMvcTest(OrderController.class)
@Import(SecurityConfiguration.class)
class OrderSecurityTests {

    @Autowired
    MockMvc mvc;

    @Test
    void readRequiresAuthentication() throws Exception {
        mvc.perform(get("/api/orders/{id}", UUID.randomUUID()))
            .andExpect(status().isUnauthorized());
    }

    @Test
    void readRejectsMissingScope() throws Exception {
        mvc.perform(get("/api/orders/{id}", UUID.randomUUID())
                .with(jwt().authorities(
                    new SimpleGrantedAuthority("SCOPE_profile.read")
                )))
            .andExpect(status().isForbidden());
    }

    @Test
    void readAcceptsRequiredScope() throws Exception {
        mvc.perform(get("/api/orders/{id}", UUID.randomUUID())
                .with(jwt().authorities(
                    new SimpleGrantedAuthority("SCOPE_orders.read")
                )))
            .andExpect(status().isOk());
    }
}

Mock JWT tests verify authorization rules but do not prove decoder configuration. Add integration tests using tokens signed by a disposable test issuer or controlled test key. Cover:

  • wrong issuer;
  • wrong audience;
  • expired and not-yet-valid tokens;
  • an unsupported algorithm;
  • an unknown key identifier;
  • missing scope;
  • cross-tenant object access;
  • key rotation;
  • unavailable discovery or JWK endpoint;
  • a token issued for a different service.

An API that accepts a validly signed wrong-audience token has failed a more important test than an API with imperfect login-page styling.

Production Checklist

  • Access tokens, ID tokens, and application sessions have distinct roles.
  • Every resource server validates issuer, signature, time claims, and audience.
  • Allowed signing algorithms are constrained.
  • Scopes and provider-specific roles are mapped deliberately.
  • Object- and tenant-level authorization is enforced in domain operations.
  • User-facing public clients use Authorization Code with PKCE.
  • Workloads use separate client identities with narrow permissions.
  • Token propagation and exchange boundaries are documented.
  • Secrets and signing keys are stored and rotated appropriately.
  • Raw tokens and sensitive claims are excluded from logs.
  • Key rotation, provider failure, and rejection paths are tested.
  • Authentication and authorization decisions are auditable.

References

The most important security improvement is not adding another token claim. It is making every trust decision explicit: who issued the credential, who it was issued to, what it authorizes, how that authorization reaches the business object, and which failures the service must reject.