In a monolith, security is a session cookie and a filter. In a microservice platform, a single user request crosses many services, and each one must independently answer “who is calling, and are they allowed to do this?” — without trusting the network. This deep dive covers how enterprise Java teams secure Spring Boot services with OAuth2 and OpenID Connect: validating JWTs, service-to-service auth, scopes and roles, token propagation, and the zero-trust mindset.
The two are constantly conflated. OAuth2 is an authorization framework: an authorization server issues an access token that grants a client access to protected resources. OpenID Connect (OIDC) is a thin identity layer on top of OAuth2 that adds authentication and returns an ID token — a signed statement about who the user is. Rule of thumb: log a user in with OIDC; call an API with an OAuth2 access token. In a Fortune 500 the authorization server is your identity provider (IdP): Microsoft Entra ID, Okta, Ping, ForgeRock, or Amazon Cognito.
Access tokens are usually JWTs — JSON Web Tokens — a base64url header, payload (claims like sub, scope, exp, aud), and a signature. The signature is the magic: any service can verify a JWT locally using the IdP’s public keys (fetched from its JWKS endpoint), with no network call back to the IdP per request. That statelessness is exactly what a distributed system needs — every service validates independently and scales horizontally.
Make each API a stateless OAuth2 resource server. Add the starter, point it at your issuer, and Spring Security validates the JWT’s signature, expiry, issuer, and audience on every request — rejecting anything invalid before your code runs.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://login.example.com/ # JWKS auto-discovered
audiences: orders-api # reject tokens not meant for us
Validating the audience matters more than people think: without it, a token minted for another API would be accepted by yours. Always pin the expected audience (and issuer).
Authentication is “who are you”; authorization is “what may you do.” Drive it from token claims — OAuth2 scopes (coarse, what the client app may do) and roles/groups (often what the user is). Configure URL rules and method-level checks:
@Configuration
@EnableMethodSecurity
class SecurityConfig {
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(a -> a
.requestMatchers(HttpMethod.GET, "/orders/**").hasAuthority("SCOPE_orders.read")
.requestMatchers(HttpMethod.POST, "/orders/**").hasAuthority("SCOPE_orders.write")
.anyRequest().authenticated())
.oauth2ResourceServer(o -> o.jwt(org.springframework.security.config.Customizer.withDefaults()))
.csrf(c -> c.disable()); // stateless API with bearer tokens, no cookies
return http.build();
}
}
@Service
class OrderService {
@PreAuthorize("hasRole('ORDER_ADMIN')")
public void cancelAny(String orderId) { /* privileged */ }
}
By default Spring maps the scope claim to SCOPE_* authorities; map your IdP’s group/role claim to ROLE_* with a custom JwtAuthenticationConverter. The principle throughout is least privilege: issue the narrowest scopes that work, and check them at the edge of each operation.
When service A calls service B with no user in the loop (a scheduled job, an internal pipeline), there is no user token to forward. Use the OAuth2 client credentials grant: A authenticates to the IdP with its own identity and receives an access token representing the service, then calls B with it as a bearer token. B validates it like any other JWT. Spring Security’s OAuth2 client handles acquisition and caching/refresh.
spring:
security:
oauth2:
client:
registration:
billing:
provider: idp
client-id: ${BILLING_CLIENT_ID}
client-secret: ${BILLING_CLIENT_SECRET}
authorization-grant-type: client_credentials
scope: billing.write
provider:
idp:
token-uri: https://login.example.com/oauth2/token
On cloud platforms, prefer a managed identity (Azure) or IAM role / workload identity federation (AWS) over a static client secret where the IdP supports it — same flow, no long-lived secret to leak or rotate.
When a request does originate from a user and fans out across services, you must decide how identity flows. Two common patterns: token relay (forward the user’s access token downstream, so each hop sees the real user and can enforce their permissions) and token exchange (swap the incoming token for a narrower one scoped to the downstream call). Token relay is simplest; token exchange is safer because it limits how far a token’s power travels. Either way, propagate the trace ID alongside it so you can correlate the secured call across your logs and traces.
The old model — a hard perimeter, soft inside — fails the moment an attacker is inside the network or a service is compromised. Zero trust assumes the network is hostile and authenticates/authorizes every call, internal ones included. Practically, for Java platforms that means: every service is a resource server that validates tokens even for internal traffic; mutual TLS (mTLS) between services (often provided transparently by a service mesh like Istio or Linkerd) so each end of a connection proves its identity; and short-lived tokens so a leaked credential expires quickly.
Access tokens should be short-lived (minutes), which limits the damage of a leak; clients use a longer-lived refresh token to get new ones. The hard part of stateless JWTs is revocation — you can’t un-issue a token that services validate offline. Mitigations: keep access tokens short so they self-expire fast; maintain a denylist of revoked token IDs (jti) for emergencies; and rotate signing keys if a key is compromised. Accept the trade-off consciously: statelessness buys scale at the cost of instant revocation.
Securing enterprise Java microservices comes down to a consistent model: authenticate with OIDC, authorize with OAuth2 access tokens, validate every JWT (signature, issuer, audience, expiry) in each stateless resource server, use client credentials or workload identity for service-to-service calls, enforce least-privilege scopes and roles, and adopt zero trust so no call is trusted just because it came from inside. Spring Security turns most of this into configuration — the engineering is in applying it uniformly and keeping privileges tight.
What is the difference between OAuth2 and OIDC?
OAuth2 is an authorization framework — it issues access tokens that grant access to resources. OpenID Connect (OIDC) is an identity layer on top of OAuth2 that adds authentication, returning an ID token with verified information about who the user is. APIs authorize with OAuth2 access tokens; login/SSO uses OIDC.
How do microservices authenticate to each other?
For service-to-service calls with no end user, use the OAuth2 client credentials grant: the calling service obtains an access token from the identity provider using its own client ID/secret (or a managed identity) and presents it as a bearer token. The receiving service validates it like any other JWT.