Intermediate · Secure
Use OAuth 2.0 and OpenID Connect Without Reinventing Login
Understand authorization servers, clients, resource servers, scopes, ID tokens, and access tokens.
Primary source: Spring Security 6.5 Reference Architecture
Lesson 4 of 5
1d streakThe Architectural Question
Hand-rolled login mishandles recovery, MFA, and session lifecycle that identity providers solve daily—and resource servers that skip claim validation accept tokens meant for other clients.
What this guide proves
- Use OIDC for user identity and OAuth2 access tokens for APIs
- Validate issuer and audience at the resource server
- Keep provider-specific claims behind an application mapping
OAuth 2.0 Roles & The Authorization Code Flow with PKCE
OAuth 2.0 is an authorization framework that allows a third-party application to obtain limited access to an HTTP service. OpenID Connect (OIDC) is an identity layer built on top of OAuth 2.0 that adds user authentication and an ID Token.
| OAuth 2.0 Role | Enterprise Example | Responsibility |
|---|---|---|
| Resource Owner | End User / Customer | Owns the data and grants access permissions |
| Client | Single Page App or Mobile App | Requests access tokens to call protected APIs |
| Authorization Server | Keycloak, Auth0, Okta, Azure AD | Authenticates the user and issues cryptographically signed tokens |
| Resource Server | Spring Boot REST API | Accepts access tokens and validates permissions before serving data |
PKCE (RFC 7636) is Mandatory
Active Recall Checkpoint
What vulnerability does PKCE prevent in mobile and single-page applications?
Hint: What happens if a malicious app on a phone intercepts the authorization code?
Show answer & explanation
Authorization code interception attacks. Without knowing the cryptographic code_verifier secret stored in memory by the legitimate client, an attacker who steals the authorization code cannot exchange it for an access token.
Configuring Spring Boot as an OAuth2 Resource Server
With spring-boot-starter-oauth2-resource-server, turning your Spring Boot API into a zero-trust Resource Server requires minimal declarative configuration. Spring Boot discovers the public keys automatically using the OIDC discovery document (/.well-known/openid-configuration).
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: "https://auth.example.com/realms/production"
audiences: "https://api.example.com"At startup, Spring Boot contacts the issuer-uri, retrieves the JWKS (JSON Web Key Set) URI, downloads the identity provider's public signing certificates, and caches them in memory. After keys are cached, bearer-token verification is local in this path; latency still depends on token size, algorithm, hardware, and the rest of the request.
Active Recall Checkpoint
What happens if the authorization server rotates its signing keys while your Spring Boot resource server is running?
Hint: Does Spring Boot reload keys dynamically or require a reboot?
Show answer & explanation
Spring Security's NimbusJwtDecoder caches keys. When an incoming token has an unknown kid (Key ID), it automatically refreshes the JWKS cache from the server to pick up newly rotated keys without requiring an application restart.
Custom JwtAuthenticationConverter: Mapping IdP Claims to Authorities
By default, Spring Security maps JWT scopes (the scope or scp claim) to granted authorities prefixed with SCOPE_. However, enterprise identity providers like Keycloak or Auth0 place roles inside nested claims (e.g. realm_access.roles). You must configure a custom JwtAuthenticationConverter to extract these roles.
package com.example.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Configuration
public class JwtConverterConfig {
@Bean
public Converter<Jwt, ? extends AbstractAuthenticationToken> jwtAuthenticationConverter() {
return jwt -> {
Map<String, Object> realmAccess = jwt.getClaim("realm_access");
Collection<GrantedAuthority> authorities = List.of();
if (realmAccess != null && realmAccess.containsKey("roles")) {
List<String> roles = (List<String>) realmAccess.get("roles");
authorities = roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
.collect(Collectors.toSet());
}
return new JwtAuthenticationToken(jwt, authorities, jwt.getClaimAsString("preferred_username"));
};
}
}Active Recall Checkpoint
Why is mapping custom IdP role claims to ROLE_<NAME> necessary for @PreAuthorize("hasRole('ADMIN')")?
Hint: What authority name does hasRole('ADMIN') expect in the SecurityContext?
Show answer & explanation
hasRole('ADMIN') strictly requires a GrantedAuthority named ROLE_ADMIN. Without the custom converter mapping the claim into ROLE_ADMIN, authorization fails with 403 Forbidden.
Deliberate Failure Lab: Network Partition & JWKS Resolution Failure
If your Spring Boot Resource Server boots before the Identity Provider (Keycloak/Auth0) is reachable over the network, startup can fail with a connection timeout:
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'jwtDecoder':
Cannot resolve issuer [https://auth.example.com]: Connection refused (Connection refused)Resilient Deployment Order
Active Recall Checkpoint
How can you run local integration tests for your Resource Server without connecting to a real external Keycloak or Okta server?
Hint: Consider how MockMvc or local JWT test builders work.
Show answer & explanation
Use SecurityMockMvcRequestPostProcessors.jwt() to inject synthetic mock JWT tokens directly into MockMvc, or use a local embedded JWKS mock server (such as WireMock or @Testcontainers).
Runtime Architecture & Flow
High-value visual model
OAuth2 Authorization Code + PKCE
The browser never receives a client secret. The authorization code is short-lived and useful only with the original PKCE verifier.
1. Redirect to authorization server
The request includes client id, redirect URI, scopes, challenge, and state.
2. User authenticates and consents
The provider proves identity and returns an authorization code.
3. Code exchanged with verifier
The client sends the PKCE verifier so a stolen code alone is not enough.
4. Tokens issued and validated
The API validates issuer, audience, signature, expiry, and required authority.
Working Implementation
Code Walkthrough
Connect a local identity provider and secure the API as a resource server.
package com.example.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.web.SecurityFilterChain;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Configuration
public class OAuth2ResourceServerLab {
@Bean
SecurityFilterChain resourceServerChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/tasks/**").authenticated()
.anyRequest().denyAll()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(customJwtConverter()))
)
.build();
}
@Bean
Converter<Jwt, ? extends AbstractAuthenticationToken> customJwtConverter() {
return jwt -> {
Map<String, Object> realmAccess = jwt.getClaim("realm_access");
List<String> roles = realmAccess != null ? (List<String>) realmAccess.get("roles") : List.of();
var authorities = roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
.collect(Collectors.toSet());
return new JwtAuthenticationToken(jwt, authorities, jwt.getSubject());
};
}
}Synthesis & Production Verification
Production Acceptance Criteria
- Distinguish OAuth 2.0 authorization flows from OIDC authentication
- Enforce issuer and audience checks on every accepted JWT
- Translate provider claims into application authorities behind one mapper
Key Takeaway
Spring Boot OAuth2 Resource Server validates signed JWT tokens locally using JWKS discovery, converting custom identity provider claims into Spring Security granted authorities.
Architectural & Technical Review
Core Interview Questions
Q01What are the core roles in OAuth 2.0 and which flow is required for public clients?Show answer
Roles: Resource Owner, Client, Authorization Server, Resource Server. Public clients (SPAs and mobile apps) must use Authorization Code Flow with PKCE (RFC 7636).
Q02How does Spring Boot configure a JWT Resource Server with minimal properties?Show answer
By setting spring.security.oauth2.resourceserver.jwt.issuer-uri, Spring Boot automatically discovers the OIDC metadata, downloads public JWKS signing keys, and validates signatures locally.
Q03Why is a custom JwtAuthenticationConverter needed for providers like Keycloak or Auth0?Show answer
Spring Security maps scopes to SCOPE_ authorities by default. Identity providers store roles in custom claims (e.g. realm_access.roles), requiring a converter to map them into ROLE_ authorities.
Production Pitfalls
Critical Anti-Patterns
- Pitfall: Using deprecated Implicit flow instead of Authorization Code with PKCE — Implicit flow exposes access tokens directly in URL fragments and is deprecated in OAuth 2.1.
- Pitfall: Failing to map identity provider custom role claims to Spring ROLE_ authorities — hasRole("ADMIN") checks specifically for ROLE_ADMIN; unmapped claims result in unexpected 403 Forbidden errors.
Primary Documentation & Specifications
Lesson Primary References
Supplementary Module Resources