OAuth2 + OpenID Connect in Spring Boot: A Practical Guide for Java Backend Engineers
Most "JWT" tutorials in 2026 conflate two different things: OAuth2 (an authorization framework) and OpenID Connect (an identity layer built on top). In production, you rarely use JWTs without OAuth2 — and you almost never use OAuth2 without OIDC if you need to know who the user is. This post walks through the practical patterns: when to use which flow, how to wire Spring Security as both client and resource server, and the gotchas that trip up teams shipping federated identity for the first time.
OAuth2 vs OpenID Connect: The Distinction That Matters
OAuth2 is for authorization — "can this app access this resource on the user's behalf?" It issues access tokens. It does not, by itself, tell you who the user is.
OpenID Connect (OIDC) is for authentication — "who is this user?" It issues ID tokens (always JWTs) alongside the access token, in a standardized format with claims like sub, email, name, iss, aud.
If your service needs to know the user's identity, you need OIDC. If your service just needs to verify a token from a trusted issuer (e.g., to call a downstream API), OAuth2 alone is fine. Most production systems use both.
The Four Flows and When to Use Each
| Flow | Use Case | Token Type |
|---|---|---|
| Authorization Code + PKCE | SPAs, mobile apps, native apps | Access + ID token via redirect |
| Authorization Code (with client secret) | Server-side web apps | Access + ID token via backend |
| Client Credentials | Service-to-service, no user | Access token only |
| Refresh Token | Long-lived sessions | New access token |
For modern browser-based apps, Authorization Code with PKCE is the recommended flow — it eliminates the client secret requirement, works on public clients, and is the standard for SPAs. Service-to-service calls use Client Credentials.
Spring Security OAuth2 Resource Server
The most common pattern: your Spring Boot API is a resource server that validates access tokens issued by an external authorization server (Keycloak, Auth0, Okta, Azure AD, AWS Cognito). You don't issue tokens — you just verify them.
Maven Setup
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
That's it for dependencies. Spring Boot auto-configures most of what you need.
application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com/realms/myapp
# OR use jwk-set-uri directly if discovery is unavailable:
# jwk-set-uri: https://auth.example.com/realms/myapp/protocol/openid-connect/certs
When you specify issuer-uri, Spring Security fetches the OIDC discovery document from ${issuer-uri}/.well-known/openid-configuration, then loads the JWK Set, then validates tokens against the issuer. This is the production-ready approach — the alternative (hardcoded JWK Set) leaves you with no way to handle key rotation.
Security Configuration
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class ResourceServerConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasAuthority("SCOPE_admin")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(new JwtAuthenticationConverter()))
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.csrf(AbstractHttpConfigurer::disable);
return http.build();
}
}
SCOPE_admin is the default authority prefix that Spring applies to each scope claim in the JWT. To use a custom prefix (e.g., ROLE_), configure a JwtAuthenticationConverter with a JwtGrantedAuthoritiesConverter.
Extracting Custom Claims
The default behavior gives you scopes as authorities. To extract custom claims like tenant_id or user_role:
@Component
public class TenantContextFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth instanceof JwtAuthenticationToken jwtAuth) {
String tenantId = jwtAuth.getToken().getClaimAsString("tenant_id");
if (tenantId != null) {
TenantContext.set(tenantId);
}
}
try {
filterChain.doFilter(request, response);
} finally {
TenantContext.clear();
}
}
}
This pattern — pulling tenant context out of the JWT into a ThreadLocal for downstream services — is something we use across all multi-tenant SaaS deployments at Orglance Technologies.
Spring Security OAuth2 Client
If your app issues tokens (e.g., you are a gateway or BFF), you're an OAuth2 client. The setup is more involved:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
spring:
security:
oauth2:
client:
registration:
myapp:
client-id: ${OAUTH_CLIENT_ID}
client-secret: ${OAUTH_CLIENT_SECRET}
scope: openid, profile, email
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
provider:
myapp:
issuer-uri: https://auth.example.com/realms/myapp
After login, the user has an OAuth2AuthenticationToken in the SecurityContext with the access token, ID token, and refresh token attached. You can pull them out for downstream API calls.
Validating Tokens: What to Actually Check
Spring's default JWT decoder checks four things:
- Signature — verified against the JWK Set from the issuer
-
Expiry (
expclaim) — must be in the future -
Issuer (
issclaim) — must match the configuredissuer-uri -
Audience (
audclaim) — by default, NOT checked
That last one bites people in production. If you don't validate aud, a token issued for another application at the same authorization server will be accepted by yours. Add a custom validator:
@Bean
public OAuth2TokenValidator<Jwt> audienceValidator() {
return new JwtClaimValidator<List<String>>("aud", aud ->
aud != null && aud.contains("my-api"));
}
@Bean
public JwtDecoder jwtDecoder() {
NimbusJwtDecoder decoder = NimbusJwtDecoder
.withIssuerLocation(issuerUri)
.build();
OAuth2TokenValidator<Jwt> withAudience = new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefault(),
audienceValidator()
);
decoder.setJwtValidator(withAudience);
return decoder;
}
Common Gotchas
Don't roll your own OAuth2 server. Use Keycloak, Auth0, Okta, AWS Cognito, Azure AD B2C. The number of subtle security mistakes in custom OAuth2 implementations is staggering. At Orglance Technologies, we use Keycloak for self-hosted deployments and Auth0/Okta for SaaS clients — both have battle-tested OIDC implementations and handle edge cases like key rotation, refresh token rotation, and consent that take years to get right in-house.
Cache the JWK Set. Spring Security does this for you with a 5-minute default TTL. Don't bypass it — fetching keys on every request is both slow and a rate-limit liability.
Refresh token rotation. When using refresh tokens, the authorization server should rotate them on each use and invalidate the old one. If your auth server doesn't do this, find a different one — non-rotating refresh tokens are a known replay-attack vector.
Clock skew. JWT validation can fail in production due to clock drift between servers. Configure decoder.setClockSkewSeconds(60) to allow 1 minute of slack.
Don't store tokens in localStorage. For SPAs, use a BFF (backend-for-frontend) pattern where tokens live in HttpOnly cookies on the server side. LocalStorage is exposed to XSS; HttpOnly cookies are not.
Logout needs thought. A federated logout requires calling the authorization server's end-session endpoint with the ID token as a hint. Spring Security supports this, but only if you opt in via OidcClientInitiatedLogoutSuccessHandler. The default behavior just clears the local session — the user is still logged in at the IdP.
Conclusion
OAuth2 + OIDC in Spring Boot is mostly configuration once you understand the distinction: resource server validates tokens, client issues them. The patterns above scale to any production deployment — single-tenant, multi-tenant, federated identity, B2B SSO.
For multi-tenant SaaS, the next consideration is scoping tokens per tenant. For healthcare systems, you'll need to integrate with ABHA-style identity providers where the user is identified by a 14-digit ABHA number rather than an email. Both are follow-on topics.
Next in this series: RBAC with @PreAuthorize — the method security layer that turns OIDC claims into business-level authorization rules.
Author: Harikrushna V is the founder of Orglance Technologies and SnowCare Health Tech, building enterprise Java systems and healthcare IT infrastructure. Connect on LinkedIn.
Top comments (0)