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

50 min

Lesson 4 of 5

1d streak

The 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 RoleEnterprise ExampleResponsibility
Resource OwnerEnd User / CustomerOwns the data and grants access permissions
ClientSingle Page App or Mobile AppRequests access tokens to call protected APIs
Authorization ServerKeycloak, Auth0, Okta, Azure ADAuthenticates the user and issues cryptographically signed tokens
Resource ServerSpring Boot REST APIAccepts access tokens and validates permissions before serving data

PKCE (RFC 7636) is Mandatory

For public clients, Authorization Code with PKCE is the recommended flow. The implicit flow is deprecated because returning access tokens directly in browser-facing responses increases exposure; follow the authorization server and current OAuth guidance.

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).

application.yml
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.

JwtConverterConfig.java
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:

startup-jwks-failure.log
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

In Kubernetes or Docker Compose, ensure the identity provider container has completed healthy startup before starting dependent resource servers, or use explicit JWKS public key configurations for isolated integration tests.

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.

OAuth2 browser redirect sequence: Separate authentication with the identity provider from authorization inside your API.

Working Implementation

Code Walkthrough

Connect a local identity provider and secure the API as a resource server.

OAuth2ResourceServerLab.java
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());
        };
    }
}
Resource Server validates JWT signatures locally using public keys fetched from the issuer-uri JWKS endpoint.
Custom JwtAuthenticationConverter maps nested IdP claims (Keycloak realm_access.roles) to Spring ROLE_ authorities.
anyRequest().denyAll() enforces Default Deny security posture for all unmapped endpoints.

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

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

Supplementary Module Resources