Command Palette

Search for a command to run...

[Advanced Spring Boot] Your Own Authorization Server: Spring Authorization Server and Keycloak

Basics 35 signed its own tokens: a login endpoint, one RSA key pair, a NimbusJwtEncoder, and a resource server that trusted that one key. Article 13 moved the job to Keycloak and covered the OAuth2 and OpenID Connect vocabulary, the authorization-code flow with PKCE, the realm setup and login with Google and GitHub. This article builds the authorization server itself, as a Spring Boot application: registered clients, the protocol endpoints, the login and consent pages, the signing keys, and the tables that remember what was granted.

The examples use Spring Boot 4.1.1 and Java 21 with PostgreSQL 18, plus Keycloak 26 for the comparison at the end. The authorization server runs on port 8214 and the resource server on 9214. Timings carry the one-minute load average at the time and are indicative. Client secrets, user passwords and RSA keys were generated for these examples with openssl and are shown on purpose; none of them protects anything.

A server with a key issuing a token in three coloured parts

The first half builds and probes a server made of properties and Boot defaults; the second half replaces each default with a piece you own and ends with the choice between this and Keycloak.

Spring Authorization Server in Spring Security 7

Spring Authorization Server used to be a separate project with its own 1.x versions. In the Spring Boot 4.1.1 BOM it has no version of its own: spring-security-oauth2-authorization-server is one of the 25 artifacts that spring-security-bom 7.1.1 manages, released with the rest of Spring Security. The Java package is unchanged, org.springframework.security.oauth2.server.authorization, and the jar still ships the three schema scripts used later. What moved is the configuration DSL: the configurer now lives in spring-security-config as org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization.OAuth2AuthorizationServerConfigurer, reached through HttpSecurity.oauth2AuthorizationServer(...), and OAuth2AuthorizationServerConfiguration sits in org.springframework.security.config.annotation.web.configuration without the static applyDefaultSecurity(http) that SAS 1.x tutorials call. Code copied from those tutorials does not compile against 7.1.1.

Which starter: spring-boot-starter-security-oauth2-authorization-server

The Boot 4.1.1 BOM manages two starters with nearly the same name:

ArtifactDescription in its pomWhat it contains
spring-boot-starter-security-oauth2-authorization-serverStarter for using Spring Authorization Server featuresthe dependencies below; this is what Initializr writes
spring-boot-starter-oauth2-authorization-serverStarter for using Spring Authorization Server features (deprecated in favor of spring-boot-starter-security-oauth2-authorization-server)the same four dependencies; the jar holds only a manifest, a licence and a notice

The lab project came from Initializr with the oauth2-authorization-server id, next to what the later sections need:

Bash
curl -s "https://start.spring.io/starter.zip?type=gradle-project&language=java&bootVersion=4.1.1&javaVersion=21&groupId=com.example&artifactId=authserver&name=authserver&packageName=com.example.authserver&dependencies=web,security,oauth2-authorization-server,jdbc,postgresql,flyway" -o authserver.zip
authserver/build.gradle
dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-flyway'
	implementation 'org.springframework.boot:spring-boot-starter-jdbc'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-security-oauth2-authorization-server'
	implementation 'org.springframework.boot:spring-boot-starter-webmvc'
	implementation 'org.flywaydb:flyway-database-postgresql'
	runtimeOnly 'org.postgresql:postgresql'
	testImplementation 'org.springframework.boot:spring-boot-starter-flyway-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-jdbc-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-security-oauth2-authorization-server-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-security-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
Bash
./gradlew dependencies --configuration runtimeClasspath
Text
+--- org.springframework.boot:spring-boot-starter-security-oauth2-authorization-server -> 4.1.1
|    +--- org.springframework.boot:spring-boot-starter:4.1.1 (*)
|    +--- org.springframework.boot:spring-boot-starter-security:4.1.1 (*)
|    +--- org.springframework.boot:spring-boot-starter-webmvc:4.1.1
|    \--- org.springframework.boot:spring-boot-security-oauth2-authorization-server:4.1.1
|         +--- org.springframework.boot:spring-boot-security:4.1.1 (*)
|         +--- org.springframework.boot:spring-boot:4.1.1 (*)
|         \--- org.springframework.security:spring-security-oauth2-authorization-server:7.1.1
|              +--- org.springframework.security:spring-security-web:7.1.1 (*)
|              +--- org.springframework.security:spring-security-oauth2-core:7.1.1
|              +--- org.springframework.security:spring-security-oauth2-jose:7.1.1
|              |    \--- com.nimbusds:nimbus-jose-jwt:10.9.1
|              +--- org.springframework.security:spring-security-oauth2-resource-server:7.1.1
|              +--- org.springframework:spring-core:7.0.9 (*)
|              +--- com.nimbusds:nimbus-jose-jwt:10.9.1
|              \--- tools.jackson.core:jackson-databind:3.2.1 -> 3.1.5 (*)
  • spring-boot-security-oauth2-authorization-server is Boot's auto-configuration: OAuth2AuthorizationServerAutoConfiguration and OAuth2AuthorizationServerJwtAutoConfiguration, plus the properties below.
  • spring-security-oauth2-resource-server is on the path because the authorization server validates its own access tokens at /userinfo.
  • jackson-databind:3.2.1 -> 3.1.5: the module was built against Jackson 3.2.1 and Boot's BOM pins 3.1.5. Everything in this article ran on 3.1.5, including the JSON the JDBC services store.

The smallest authorization server: registered clients from properties

With no code at all, Boot builds the server from spring.security.oauth2.authorizationserver.*. Three clients cover the flows of this article: a service that calls APIs as itself, a server-side web application that logs users in, and a single-page application with no secret. One user comes from the ordinary spring.security.user.* properties.

authserver/src/main/resources/application.properties
server.port=8214
 
spring.security.user.name=alice
spring.security.user.password=Iua27v1PAG-AQ772BVq-TZts
 
spring.security.oauth2.authorizationserver.client.reporting-service.registration.client-id=reporting-service
spring.security.oauth2.authorizationserver.client.reporting-service.registration.client-secret={noop}YKEH82IfQ7fwVscNczMHPi-X
spring.security.oauth2.authorizationserver.client.reporting-service.registration.client-authentication-methods=client_secret_basic
spring.security.oauth2.authorizationserver.client.reporting-service.registration.authorization-grant-types=client_credentials
spring.security.oauth2.authorizationserver.client.reporting-service.registration.scopes=catalog.read
 
spring.security.oauth2.authorizationserver.client.web-app.registration.client-id=web-app
spring.security.oauth2.authorizationserver.client.web-app.registration.client-secret={noop}qPzyIVmf35_MSv6B67GLD6-i
spring.security.oauth2.authorizationserver.client.web-app.registration.client-authentication-methods=client_secret_basic
spring.security.oauth2.authorizationserver.client.web-app.registration.authorization-grant-types=authorization_code,refresh_token
spring.security.oauth2.authorizationserver.client.web-app.registration.redirect-uris=http://127.0.0.1:10214/login/oauth2/code/web-app
spring.security.oauth2.authorizationserver.client.web-app.registration.scopes=openid,profile,catalog.read,catalog.write
spring.security.oauth2.authorizationserver.client.web-app.require-authorization-consent=true
 
spring.security.oauth2.authorizationserver.client.spa.registration.client-id=spa
spring.security.oauth2.authorizationserver.client.spa.registration.client-authentication-methods=none
spring.security.oauth2.authorizationserver.client.spa.registration.authorization-grant-types=authorization_code
spring.security.oauth2.authorizationserver.client.spa.registration.redirect-uris=http://127.0.0.1:10214/spa/callback
spring.security.oauth2.authorizationserver.client.spa.registration.scopes=openid,catalog.read

The datasource lines for PostgreSQL are left out; nothing uses the database until the persistence section. The redirect URIs name port 10214, where a real client would listen; curl reads the Location header and never follows it, so nothing runs there. {noop} tells the default DelegatingPasswordEncoder that the secret is stored as plain text, acceptable for a lab file and replaced by BCrypt hashes later.

The property tree, read from the metadata and the bytecode of OAuth2AuthorizationServerProperties in spring-boot-security-oauth2-authorization-server 4.1.1:

Property under spring.security.oauth2.authorizationserver.DefaultNotes
issuernoneresolved from each request when unset; the resource server section shows why to set it
client.<key>.registration.client-id, client-secret, client-namenone<key> is only the map key
client.<key>.registration.client-authentication-methods, authorization-grant-types, redirect-uris, post-logout-redirect-uris, scopesnonecomma-separated sets
client.<key>.require-proof-keytruePKCE for every client, confidential ones included
client.<key>.require-authorization-consentfalsetrue shows the consent page
client.<key>.jwk-set-uri, token-endpoint-authentication-signing-algorithmnonefor private_key_jwt and client_secret_jwt clients
client.<key>.token.access-token-time-to-live5 minutesauthorization-code-time-to-live and device-code-time-to-live are also 5 minutes
client.<key>.token.access-token-formatself-containedreference issues opaque tokens
client.<key>.token.refresh-token-time-to-live, reuse-refresh-tokens60 minutes, truearticle 15
client.<key>.token.id-token-signature-algorithmRS256
endpoint.*/oauth2/authorize, /oauth2/token, …one property per endpoint path
multiple-issuers-allowednoneseveral issuers on one host, told apart by path

require-proof-key deserves the second look. The field is a plain boolean that the constructor of OAuth2AuthorizationServerProperties$Client sets to true, so every client configured this way must send PKCE. A confidential client registered without the property and asked for a code without code_challenge was sent back with error=invalid_request&error_description=OAuth%202.0%20Parameter%3A%20code_challenge. ClientSettings.builder() in 7.1.1 defaults to the same: its settings printed settings.client.require-proof-key=true and settings.client.require-authorization-consent=false.

The server started in 1.5 seconds. spring.security.user.* still creates the in-memory user, as the startup log reports (log excerpts in this article drop the timestamp, level, process and thread columns):

Text
r$InitializeUserDetailsManagerConfigurer : Global AuthenticationManager configured with UserDetailsService bean with name inMemoryUserDetailsManager
c.e.authserver.AuthserverApplication     : Started AuthserverApplication in 1.493 seconds (process running for 1.721)

The discovery documents and the endpoints they advertise

The server publishes two metadata documents, one for OpenID Connect (OpenID Connect Discovery 1.0) and one for plain OAuth2 (RFC 8414):

Bash
curl -s http://localhost:8214/.well-known/openid-configuration | jq .
JSON
{
  "issuer": "http://localhost:8214",
  "authorization_endpoint": "http://localhost:8214/oauth2/authorize",
  "token_endpoint": "http://localhost:8214/oauth2/token",
  "token_endpoint_auth_methods_supported": [
    "client_secret_basic",
    "client_secret_post",
    "client_secret_jwt",
    "private_key_jwt",
    "tls_client_auth",
    "self_signed_tls_client_auth"
  ],
  "jwks_uri": "http://localhost:8214/oauth2/jwks",
  "userinfo_endpoint": "http://localhost:8214/userinfo",
  "end_session_endpoint": "http://localhost:8214/connect/logout",
  "response_types_supported": [
    "code"
  ],
  "grant_types_supported": [
    "authorization_code",
    "client_credentials",
    "refresh_token",
    "urn:ietf:params:oauth:grant-type:token-exchange"
  ],
  "revocation_endpoint": "http://localhost:8214/oauth2/revoke",
  "revocation_endpoint_auth_methods_supported": [
    "client_secret_basic",
    "client_secret_post",
    "client_secret_jwt",
    "private_key_jwt",
    "tls_client_auth",
    "self_signed_tls_client_auth"
  ],
  "introspection_endpoint": "http://localhost:8214/oauth2/introspect",
  "introspection_endpoint_auth_methods_supported": [
    "client_secret_basic",
    "client_secret_post",
    "client_secret_jwt",
    "private_key_jwt",
    "tls_client_auth",
    "self_signed_tls_client_auth"
  ],
  "code_challenge_methods_supported": [
    "S256"
  ],
  "tls_client_certificate_bound_access_tokens": true,
  "dpop_signing_alg_values_supported": [
    "RS256",
    "RS384",
    "RS512",
    "PS256",
    "PS384",
    "PS512",
    "ES256",
    "ES384",
    "ES512"
  ],
  "subject_types_supported": [
    "public"
  ],
  "id_token_signing_alg_values_supported": [
    "RS256"
  ],
  "scopes_supported": [
    "openid"
  ]
}

/.well-known/oauth-authorization-server returned the same members up to dpop_signing_alg_values_supported and none of the five OpenID ones: userinfo_endpoint, end_session_endpoint, subject_types_supported, id_token_signing_alg_values_supported and scopes_supported. The resource server later in this article, configured with issuer-uri, fetched /.well-known/openid-configuration once and then the JWKS.

EndpointAdvertised asWho calls it
/oauth2/authorizeauthorization_endpointthe browser, in the authorization_code flow
/oauth2/tokentoken_endpointevery client, for every grant
/oauth2/jwksjwks_uriresource servers that verify JWTs
/oauth2/introspectintrospection_endpointresource servers that receive opaque tokens
/oauth2/revokerevocation_endpointclients (article 15)
/userinfouserinfo_endpoint, OpenID document onlyclients holding an access token with openid
/connect/logoutend_session_endpoint, OpenID document onlyRP-initiated logout
/oauth2/device_authorization, /oauth2/device_verificationnot advertisednot enabled
/oauth2/parnot advertisednot enabled
/connect/register, /oauth2/registernot advertisednot enabled

The last three rows have paths in AuthorizationServerSettings, yet no filter serves them. The bytecode of OAuth2AuthorizationServerConfigurer.createConfigurers() in spring-security-config 7.1.1 creates six configurers by default: client authentication, the metadata endpoint, and the authorization, token, introspection and revocation endpoints. The device flow, pushed authorization requests and dynamic client registration each need their own call on the configurer (deviceAuthorizationEndpoint(...), deviceVerificationEndpoint(...), pushedAuthorizationRequestEndpoint(...), clientRegistrationEndpoint(...)), and grant_types_supported lists no device_code. Token exchange (RFC 8693) is advertised by default.

The endpoints of the discovery document grouped by the flow that uses them: discovery, the authorization_code flow with PKCE, client_credentials, resource servers, token lifecycle, and the endpoints that are not enabled by default

The two filter chains Spring Boot sets up

With logging.level.org.springframework.security.web.DefaultSecurityFilterChain=DEBUG, the startup log lists two chains:

Text
o.s.s.web.DefaultSecurityFilterChain     : Will secure org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization.OAuth2AuthorizationServerConfigurer$$Lambda/0x0000007001567dc0@5df2023c with filters: DisableEncodeUrlFilter, WebAsyncManagerIntegrationFilter, SecurityContextHolderFilter, AuthorizationServerContextFilter, HeaderWriterFilter, CsrfFilter, OidcLogoutEndpointFilter, LogoutFilter, OAuth2AuthorizationServerMetadataEndpointFilter, OAuth2AuthorizationCodeRequestValidatingFilter, OidcProviderConfigurationEndpointFilter, NimbusJwkSetEndpointFilter, OAuth2ProtectedResourceMetadataFilter, OAuth2ClientAuthenticationFilter, BearerTokenAuthenticationFilter, RequestCacheAwareFilter, SecurityContextHolderAwareRequestFilter, AnonymousAuthenticationFilter, ExceptionTranslationFilter, AuthorizationFilter, OAuth2AuthorizationEndpointFilter, OAuth2TokenEndpointFilter, OAuth2TokenIntrospectionEndpointFilter, OAuth2TokenRevocationEndpointFilter, OidcUserInfoEndpointFilter
o.s.s.web.DefaultSecurityFilterChain     : Will secure any request with filters: DisableEncodeUrlFilter, WebAsyncManagerIntegrationFilter, SecurityContextHolderFilter, HeaderWriterFilter, CsrfFilter, LogoutFilter, UsernamePasswordAuthenticationFilter, DefaultResourcesFilter, DefaultLoginPageGeneratingFilter, DefaultLogoutPageGeneratingFilter, RequestCacheAwareFilter, SecurityContextHolderAwareRequestFilter, AnonymousAuthenticationFilter, ExceptionTranslationFilter, AuthorizationFilter

Both come from OAuth2AuthorizationServerWebSecurityConfiguration in Boot's module, which is active only while the application declares no SecurityFilterChain of its own and a RegisteredClientRepository and AuthorizationServerSettings exist:

  • authorizationServerSecurityFilterChain, @Order(-2147483648) (Ordered.HIGHEST_PRECEDENCE), matches only the protocol endpoints through getEndpointsMatcher(). It enables OpenID Connect, requires authentication for anything that is not public, validates bearer tokens for /userinfo, and sends unauthenticated HTML requests to /login.
  • defaultSecurityFilterChain, @Order(2147483642), matches everything else and provides the form login. The /login page and the session that remembers the user between the hops of a flow belong to this chain.

The HTML condition is exact. The same authorization request, with and without a browser's Accept header:

Bash
curl -i -s "$AUTH_URL"
curl -i -s -H 'Accept: text/html' "$AUTH_URL"
Text
HTTP/1.1 401
Set-Cookie: JSESSIONID=CD47F99D93181EE8206D168853C54FC8; Path=/; HttpOnly
WWW-Authenticate: Bearer resource_metadata="http://localhost:8214/.well-known/oauth-protected-resource"
Content-Length: 0
 
HTTP/1.1 302
Set-Cookie: JSESSIONID=F62DAB9F49A1CCA1F1C9CC51051B16B4; Path=/; HttpOnly
Location: http://localhost:8214/login
Content-Length: 0

curl's default Accept: */* does not count as HTML: Boot's matcher is a MediaTypeRequestMatcher for text/html with MediaType.ALL ignored. Every browser-side curl command below therefore sends -H 'Accept: text/html'. Only the headers that matter are quoted from here on; the others were the usual X-Content-Type-Options, Cache-Control, X-Frame-Options and Date.

Declaring your own SecurityFilterChain beans

As soon as the application declares any SecurityFilterChain, both of Boot's chains back off, so the replacements come as a pair. This is Boot's configuration written out in the 7.1.1 DSL, plus one line for /error that the error section explains:

authserver/src/main/java/com/example/authserver/security/SecurityConfig.java
package com.example.authserver.security;
 
import java.util.Set;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
 
@Configuration
public class SecurityConfig {
 
    @Bean
    @Order(1)
    SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) {
        http
                .oauth2AuthorizationServer(authorizationServer -> {
                    http.securityMatcher(authorizationServer.getEndpointsMatcher());
                    authorizationServer.oidc(Customizer.withDefaults());
                })
                .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
                .oauth2ResourceServer(resourceServer -> resourceServer.jwt(Customizer.withDefaults()))
                .exceptionHandling(exceptions -> exceptions.defaultAuthenticationEntryPointFor(
                        new LoginUrlAuthenticationEntryPoint("/login"), htmlPages()));
        return http.build();
    }
 
    @Bean
    @Order(2)
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) {
        http
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/error").permitAll()
                        .anyRequest().authenticated())
                .formLogin(Customizer.withDefaults());
        return http.build();
    }
 
    private static MediaTypeRequestMatcher htmlPages() {
        MediaTypeRequestMatcher matcher = new MediaTypeRequestMatcher(MediaType.TEXT_HTML);
        matcher.setIgnoredMediaTypes(Set.of(MediaType.ALL));
        return matcher;
    }
}

The filter lists printed at startup were identical to Boot's, filter for filter. Each line was then removed or changed in turn, with the application rebuilt each time:

ChangeWhat happened
@Order values swappedstartup failed with UnreachableFilterChainException (quoted below)
both @Order annotations removedworked, because the beans kept their declaration order; do not rely on it
exceptionHandling(...) removedthe browser request to /oauth2/authorize got 401 with an empty body and no WWW-Authenticate: a blank page instead of the login form
authorizationServer.oidc(...) removedOidcLogoutEndpointFilter, OidcProviderConfigurationEndpointFilter and OidcUserInfoEndpointFilter left the chain; /.well-known/openid-configuration answered 302 to /login; a request with scope=openid was redirected with error=invalid_scope&error_description=OpenID%20Connect%201.0%20authentication%20requests%20are%20restricted.
oauth2ResourceServer(...) removednothing changed: the filter list still contained BearerTokenAuthenticationFilter and /userinfo answered 200 with {"sub":"alice"}. In 7.1.1 the OIDC configurer sets up bearer tokens for its endpoint itself; Boot keeps the line, and so does this configuration

The exception with the orders swapped, with the two filter lists cut to […]:

Text
Caused by: org.springframework.security.web.UnreachableFilterChainException: A filter chain that matches any request [DefaultSecurityFilterChain defined as 'defaultSecurityFilterChain' in [class path resource [com/example/authserver/security/SecurityConfig.class]] matching [any request] and having filters […]] has already been configured, which means that this filter chain [DefaultSecurityFilterChain defined as 'authorizationServerSecurityFilterChain' in [class path resource [com/example/authserver/security/SecurityConfig.class]] matching [org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization.OAuth2AuthorizationServerConfigurer$$Lambda/0x0000008801440870@31aab981] and having filters […]] will never get invoked. Please use `HttpSecurity#securityMatcher` to ensure that there is only one filter chain configured for 'any request' and that the 'any request

Running the flows with curl

client_credentials: a token for a service

Bash
curl -i -s -u reporting-service:YKEH82IfQ7fwVscNczMHPi-X -d grant_type=client_credentials -d scope=catalog.read http://localhost:8214/oauth2/token
Text
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Content-Length: 777
 
{"access_token":"eyJraWQiOiIyNjczMTA3Zi1lNTI4LTRkNjEtOTIxNC1lNzQwMTY5ODRkOGIiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJyZXBvcnRpbmctc2VydmljZSIsImF1ZCI6InJlcG9ydGluZy1zZXJ2aWNlIiwibmJmIjoxNzg5NzE1NTE0LCJzY29wZSI6WyJjYXRhbG9nLnJlYWQiXSwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MjE0IiwiZXhwIjoxNzg5NzE1ODE0LCJpYXQiOjE3ODk3MTU1MTQsImp0aSI6ImEyY2E3ZmQ4LTE2NmItNDY3Yi1hNGQ2LTM4MWU4NWM2YzI3NyJ9.ArjlsLCMyXdMLDyduV66Nk1bROJIfuYnm4Eac2wpgqq3bqb23cfvkxcHaiogRmv6wMzYkm2FQUB3ULrxkN7vl5BJE-V5lDWYTNqQGJP642RBjDhKC3b8__jwBpOiGxdtnqTfD2xw4Lq2OwArvmMLT8iPh5Q5Rz1rFFp8WLsx1kLTMWJLElH4eWkGvaery5nMyXxTtiOO31FmyvFT12-rIeCjF4sUOaG1SIxzVAjhysU_dtexBXQHy-87dKPExAB7NwA6gF3fbD0UStQcUNkFufFmVrFYr8kJxKnIC0is3ecTmjBX_-wN2UrPJnvNKgqvhr7tsg9cf78d8MQPxVboFA","scope":"catalog.read","token_type":"Bearer","expires_in":299}

Decoded with the b64url_decode function of Basics 35:

JSON
{
  "kid": "2673107f-e528-4d61-9214-e74016984d8b",
  "alg": "RS256"
}
JSON
{
  "sub": "reporting-service",
  "aud": "reporting-service",
  "nbf": 1789715514,
  "scope": [
    "catalog.read"
  ],
  "iss": "http://localhost:8214",
  "exp": 1789715814,
  "iat": 1789715514,
  "jti": "a2ca7fd8-166b-467b-a4d6-381e85c6c277"
}
  • kid is a random UUID, not the RFC 7638 thumbprint of Basics 35, and the header has no typ.
  • sub and aud are both the client id: there is no user in this grant.
  • scope is a JSON array in the token and a space-separated string in the response. Basics 35's JwtGrantedAuthoritiesConverter reads either form.
  • expiat = 300: the default access token lives 5 minutes; expires_in said 299 because a second had started.
  • jti gives every token its own id.

The key behind the kid, from jwks_uri:

Bash
curl -s http://localhost:8214/oauth2/jwks | jq .
JSON
{
  "keys": [
    {
      "kty": "RSA",
      "e": "AQAB",
      "kid": "2673107f-e528-4d61-9214-e74016984d8b",
      "n": "u3CK1Dh65ee5jjU-WsN8YdTC-UKh6cwWhImm9j8KHFTq0blTB-0qtipDbHoWmiImekafNM130fClzDqEii6coBiWNXKN_nPCoD7naP_USiN264KDBOZ4R0HBa4ScwKHiua_TJDReFxxDq0h6BOxwdpODTuzNalRp2SDrzNb4gRdIeiajJYYC0V6wo4BYlxaKUQR0zhkH2F1l2Zn6wKFESeV2f3YcIPaSadJRcCNfm-0wi9ADTieirXkKGbfwg8448wO94xL4mWgHhFg0dYCsb6PbCeWxr3EJtIMlPIJLSVJNhrHTa9DunSZst5kvpgO0cxMaRVq6cwnoDDhP2sSYSQ"
    }
  ]
}

authorization_code with PKCE, one hop per step

Article 13 explained what each step of this flow is for; here each step is one curl command against this server, with a cookie jar standing in for the browser. The PKCE pair:

Bash
VERIFIER=$(openssl rand 32 | b64url)
CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -sha256 -binary | b64url)
AUTH_URL="http://localhost:8214/oauth2/authorize?response_type=code&client_id=web-app&redirect_uri=http://127.0.0.1:10214/login/oauth2/code/web-app&scope=openid%20profile%20catalog.read&state=af0ifjsldkj&code_challenge=$CHALLENGE&code_challenge_method=S256"
Text
verifier=MomTxxPZjSh15C6IONsnNzDCk5eqo2gAGspUS95oKi8 challenge=If9Ga0pCXRVHixVYqJJHGCC6hsjkIEkQzwof5bIsZck

Hop 1, the authorization request, answered 302 to /login, as shown in the filter chain section. Hop 2, the login form: GET /login returned the default "Please sign in" page with a hidden _csrf field, because the form-login chain keeps CSRF protection. Hop 3, the login:

Bash
curl -i -s -c cj.txt -b cj.txt -H 'Accept: text/html' --data-urlencode username=alice --data-urlencode password=Iua27v1PAG-AQ772BVq-TZts --data-urlencode "_csrf=$CSRF" http://localhost:8214/login
Text
HTTP/1.1 302
Set-Cookie: JSESSIONID=225161ECABA7AF8C8927FEAB7CBE8C4F; Path=/; HttpOnly
Location: http://localhost:8214/oauth2/authorize?response_type=code&client_id=web-app&redirect_uri=http://127.0.0.1:10214/login/oauth2/code/web-app&scope=openid%20profile%20catalog.read&state=af0ifjsldkj&code_challenge=If9Ga0pCXRVHixVYqJJHGCC6hsjkIEkQzwof5bIsZck&code_challenge_method=S256&continue

The request cache replayed the original authorization request with &continue appended, and the session id changed on login. Hop 4, the consent page, answered 200 with 2660 bytes of HTML; its form, one element per line:

HTML
<title>Consent required</title>
<span class="font-weight-bold text-primary">web-app</span> wants to access your account <span class="font-weight-bold">alice</span>
<form name="consent_form" method="post" action="/oauth2/authorize">
<input type="hidden" name="client_id" value="web-app">
<input type="hidden" name="state" value="xOh7ENUuYNFP8bvm0QgN1c8xL8nOiOc5mJl6rk2D0QY=">
<input class="form-check-input" type="checkbox" name="scope" value="profile" id="profile">
<input class="form-check-input" type="checkbox" name="scope" value="catalog.read" id="catalog.read">
<button class="btn btn-primary btn-lg" type="submit" id="submit-consent">Submit Consent</button>
<button class="btn btn-link regular" type="button" onclick="cancelConsent();" id="cancel-consent">Cancel</button>

openid is not a checkbox: it is granted without asking. The state in the form is the server's own consent state, not the client's af0ifjsldkj, and the page loads Bootstrap from stackpath.bootstrapcdn.com, so a production server usually supplies its own page through authorizationEndpoint(endpoint -> endpoint.consentPage("/consent")). Hop 5, the consent, needs no CSRF token because the protocol chain ignores CSRF on its endpoints:

Bash
curl -i -s -c cj.txt -b cj.txt -H 'Accept: text/html' --data-urlencode client_id=web-app --data-urlencode "state=$CONSENT_STATE" --data-urlencode scope=profile --data-urlencode scope=catalog.read http://localhost:8214/oauth2/authorize
Text
HTTP/1.1 302
Location: http://127.0.0.1:10214/login/oauth2/code/web-app?code=k2yC0IY8_3dDLktaDiSfe4FnkoCHgaXFB6aWKU-v5w7OKeU25PPVkpmsLuCrX6iFbIZBatgnF_M4fmsWfBwcnG9d9xtVcmW_5ey_tp_IgUxL1YqhkQjXhdygrNP6JmjB&state=af0ifjsldkj

Hop 6, the token exchange, from the client's back end, with the secret and the verifier:

Bash
curl -i -s -u web-app:qPzyIVmf35_MSv6B67GLD6-i -d grant_type=authorization_code --data-urlencode "code=$CODE" --data-urlencode redirect_uri=http://127.0.0.1:10214/login/oauth2/code/web-app --data-urlencode "code_verifier=$VERIFIER" http://localhost:8214/oauth2/token
Text
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Content-Length: 1681
 
{"access_token":"eyJraWQiOiIyNjczMTA3Zi1lNTI4LTRkNjEtOTIxNC1lNzQwMTY5ODRkOGIiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhbGljZSIsImF1ZCI6IndlYi1hcHAiLCJuYmYiOjE3ODk3MTU1NjIsInNjb3BlIjpbIm9wZW5pZCIsInByb2ZpbGUiLCJjYXRhbG9nLnJlYWQiXSwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MjE0IiwiZXhwIjoxNzg5NzE1ODYyLCJpYXQiOjE3ODk3MTU1NjIsImp0aSI6IjY0NjgzZTNkLWJmMGEtNGRlMi04NTlmLTM1OGRjZTg2ZTQ3NSJ9.fAbDNACG-2ZqVRJYNrf6TP3y6LA6AbB6MvpR8OMu6cyJxcJ_9R-0zDX2F5D5A5CCigPx4n2dXDDe8R_26mtBES6KN8qyyf7eaaQCuspomzYtZvOKpgYoZBdhHp52tZc95Y9pnNd1e55_k5FZBP3bSBqcpeK-1jr-f2MIMZl6Ppu12Jz8RSrO9O7sRB8R-brn1xd8jZXywg1dfOoWw6yAdmXGytljtaZvBQrzNQAov9MiYVEEBY6Mi-Xvjm1hDTeyd76OYkBwZxunXZSce_QcKbZU3KL3vQMGdrzyh9_COLHRtNQUcAFAnKO5PCnfIOg5pGoaGygFa9OhW_W5gecbJw","refresh_token":"1hd0aIfRyBexuv_NAz1Uj5dbvw6JMdPm6Bn14hZ8GkSjgTb4UwaXoI8eXSB6ONTcmNuByR14RhVnsnhuihrSHeJeJrwocboFsBwFOi67xx5TDd1_9Sv-6jBUSLTuwYBb","scope":"openid profile catalog.read","id_token":"eyJraWQiOiIyNjczMTA3Zi1lNTI4LTRkNjEtOTIxNC1lNzQwMTY5ODRkOGIiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhbGljZSIsImF1ZCI6IndlYi1hcHAiLCJhenAiOiJ3ZWItYXBwIiwiYXV0aF90aW1lIjoxNzg5NzE1NTQzLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgyMTQiLCJleHAiOjE3ODk3MTczNjIsImlhdCI6MTc4OTcxNTU2MiwianRpIjoiNzg2ZmE3ZmYtZDMwNS00MjEyLWI5ODEtMTllNjZlMzNmYzYzIiwic2lkIjoibEluTXdqQTJOcFNweE1KMXRpaC1WM1V6cnNOOEktY3JsV0FxcEFtTVBTayJ9.kFkaqeeQBhDF1uTnZRWAW8ZNZ3qrJDq9EFxFYOcRPdBxq9v_5Am48WDsyvFnUiUIuK57QwuqEs-87OyygDeYdx5hr6qJVXki34W6Uo2zoDZEqM2aGYoPHRKrquUUfO1NVD2P3pDMXjUyRXwue6vJtPzEnK94rzl2mAQuKNt3WVPZAe0MnJV-Nt2c0a4SdxFLzMQSFxoDEMN4qjRiOlhW9vNQ__ULFkov3TjLdJNmLItRoBB5QjRaCGsM2wRkTbGBAT9KxnZxVIjVbI_zJj2fv7ZmwkfDlh5RbTk5kiBl4RmkaK3_h7QIN5-iqh9NJZt2iWraBmbGsMxN1oEuF_DE9Q","token_type":"Bearer","expires_in":299}

The access token carries "sub":"alice", "aud":"web-app" and "scope":["openid","profile","catalog.read"]. The ID token, decoded:

JSON
{
  "sub": "alice",
  "aud": "web-app",
  "azp": "web-app",
  "auth_time": 1789715543,
  "iss": "http://localhost:8214",
  "exp": 1789717362,
  "iat": 1789715562,
  "jti": "786fa7ff-d305-4212-b981-19e66e33fc63",
  "sid": "lInMwjA2NpSpxMJ1tih-V3UzrsN8I-crlWAqpAmMPSk"
}

It lives 30 minutes (expiat = 1800), auth_time is the moment of the login in hop 3, and it has no nonce because the request sent none. Because web-app has the refresh_token grant, the response also held a 128-character opaque refresh token; article 15 covers refreshing, rotating and revoking it.

The errors, with their real bodies

A wrong client secret, and an unknown client, got the same answer:

Text
HTTP/1.1 401
Content-Type: application/json;charset=UTF-8
Content-Length: 26
 
{"error":"invalid_client"}

There is no WWW-Authenticate header, although the client authenticated with HTTP Basic, and nothing tells a caller whether the client id exists. A scope the client is not registered for, -d scope=catalog.write for reporting-service, got 400 with {"error":"invalid_scope"}.

An unregistered redirect_uri must never produce a redirect, since the whole point of registering it is that the server does not send codes to strangers. With Boot's default chains, which have no rule for /error, a browser that is not logged in got this:

Bash
curl -i -s -H 'Accept: text/html' "http://localhost:8214/oauth2/authorize?response_type=code&client_id=web-app&redirect_uri=https://evil.example/callback&scope=openid&state=s1&code_challenge=$CHALLENGE&code_challenge_method=S256"
Text
HTTP/1.1 302
Set-Cookie: JSESSIONID=0D1C82FC1C1A6072CE68E5382738BA8D; Path=/; HttpOnly
Location: http://localhost:8214/login;jsessionid=0D1C82FC1C1A6072CE68E5382738BA8D

A second run with logging.level.org.springframework.security=DEBUG shows why: the request was rejected before any login, then the error dispatch went through the form-login chain as an anonymous user, which saved /error in the request cache and sent the user to the login page.

Text
o.s.security.web.FilterChainProxy        : Securing GET /oauth2/authorize?response_type=code&client_id=web-app&redirect_uri=https://evil.example/callback&scope=openid&state=s1&code_challenge=If9Ga0pCXRVHixVYqJJHGCC6hsjkIEkQzwof5bIsZck&code_challenge_method=S256
o.s.security.web.FilterChainProxy        : Securing GET /error?response_type=code&client_id=web-app&redirect_uri=https://evil.example/callback&scope=openid&state=s1&code_challenge=If9Ga0pCXRVHixVYqJJHGCC6hsjkIEkQzwof5bIsZck&code_challenge_method=S256
o.s.s.w.a.AnonymousAuthenticationFilter  : Set SecurityContextHolder to anonymous SecurityContext
o.s.s.w.s.HttpSessionRequestCache        : Saved request http://localhost:8214/error?response_type=code&client_id=web-app&redirect_uri=https://evil.example/callback&scope=openid&state=s1&code_challenge=If9Ga0pCXRVHixVYqJJHGCC6hsjkIEkQzwof5bIsZck&code_challenge_method=S256&continue to session
o.s.s.web.DefaultRedirectStrategy        : Redirecting to /login;jsessionid=D4373069EB50645E355F81CA69B3FB2E

It is the trap Basics 33 found with HTTP Basic, and Boot's default chain has it too. With requestMatchers("/error").permitAll() the same request answered the actual error:

Text
HTTP/1.1 400
Content-Type: text/html;charset=UTF-8
Content-Length: 277
 
<html><body><h1>Whitelabel Error Page</h1><p>This application has no explicit mapping for /error, so you are seeing this as a fallback.</p><div id='created'>Fri Sep 18 14:18:55 ICT 2026</div><div>There was an unexpected error (type=Bad Request, status=400).</div></body></html>

With Accept: application/json the body was Boot's {"timestamp":"2026-09-18T07:19:01.511Z","status":400,"error":"Bad Request","path":"/oauth2/authorize"}. There is no OAuth2 error code in either: the server cannot trust the redirect URI enough to put one there, so the user sees the server's own error page.

A public client without PKCE is redirected back to its registered URI with the error in the query string, since that URI is trusted:

Bash
curl -i -s -H 'Accept: text/html' "http://localhost:8214/oauth2/authorize?response_type=code&client_id=spa&redirect_uri=http://127.0.0.1:10214/spa/callback&scope=openid%20catalog.read&state=s2"
Text
HTTP/1.1 302
Location: http://127.0.0.1:10214/spa/callback?error=invalid_request&error_description=OAuth%202.0%20Parameter%3A%20code_challenge&error_uri=https%3A%2F%2Fdatatracker.ietf.org%2Fdoc%2Fhtml%2Frfc7636%23section-4.4.1&state=s2

code_challenge_method=plain got the same redirect with OAuth%202.0%20Parameter%3A%20code_challenge_method: only S256 is accepted, as the discovery document says. A public client registered without require-proof-key was rejected in the same way; PKCE is not optional for a client without a secret.

A reused authorization code, sent a second time exactly as in hop 6:

Text
HTTP/1.1 400
Content-Type: application/json;charset=UTF-8
Content-Length: 25
Connection: close
 
{"error":"invalid_grant"}

The server also invalidated what the first exchange had issued, as RFC 6749 section 4.1.2 recommends: introspecting the first access token now returned {"active":false}. A resource server that validates JWTs locally never learns this. In the later run on PostgreSQL, the same experiment gave 400 invalid_grant, {"active":false} from introspection, and still 200 from the JWT resource server for the invalidated token; article 15 deals with tokens that must stop working before they expire.

A resource server for these tokens

The resource server is Basics 35's configuration pointed at the new issuer: a project from Initializr with web,oauth2-resource-server, the same roles mapping, and ProblemDetail bodies from Basics 35's handler.

resourceserver/src/main/resources/application.properties
server.port=9214
spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:8214
spring.security.oauth2.resourceserver.jwt.authorities-claim-name=roles
spring.security.oauth2.resourceserver.jwt.authority-prefix=ROLE_
resourceserver/src/main/java/com/example/resourceserver/common/SecurityConfig.java
package com.example.resourceserver.common;
 
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
 
@Configuration
public class SecurityConfig {
 
    @Bean
    SecurityFilterChain apiSecurityFilterChain(HttpSecurity http, ProblemDetailSecurityHandler problemHandler) {
        http
                .securityMatcher("/api/**")
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/api/admin/**").hasRole("ADMIN")
                        .anyRequest().authenticated())
                .oauth2ResourceServer(oauth2 -> oauth2
                        .jwt(Customizer.withDefaults())
                        .authenticationEntryPoint(problemHandler)
                        .accessDeniedHandler(problemHandler))
                .exceptionHandling(exceptions -> exceptions
                        .authenticationEntryPoint(problemHandler)
                        .accessDeniedHandler(problemHandler))
                .csrf(csrf -> csrf.disable())
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
        return http.build();
    }
}

The handler is Basics 35's, with the 403 path now also delegating to Spring Security's Bearer handler so that it writes WWW-Authenticate with insufficient_scope:

resourceserver/src/main/java/com/example/resourceserver/common/ProblemDetailSecurityHandler.java
package com.example.resourceserver.common;
 
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URI;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;
import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import tools.jackson.databind.json.JsonMapper;
 
@Component
public class ProblemDetailSecurityHandler implements AuthenticationEntryPoint, AccessDeniedHandler {
 
    private final JsonMapper jsonMapper;
    private final BearerTokenAuthenticationEntryPoint bearerEntryPoint = new BearerTokenAuthenticationEntryPoint();
    private final BearerTokenAccessDeniedHandler bearerDeniedHandler = new BearerTokenAccessDeniedHandler();
 
    public ProblemDetailSecurityHandler(JsonMapper jsonMapper) {
        this.jsonMapper = jsonMapper;
        this.bearerEntryPoint.setRealmName("catalogue");
        this.bearerDeniedHandler.setRealmName("catalogue");
    }
 
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException ex) throws IOException {
        bearerEntryPoint.commence(request, response, ex);
        write(request, response, "Valid credentials are required to access this resource.");
    }
 
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
                       AccessDeniedException ex) throws IOException {
        bearerDeniedHandler.handle(request, response, ex);
        write(request, response, "You are not allowed to perform this operation.");
    }
 
    private void write(HttpServletRequest request, HttpServletResponse response, String detail) throws IOException {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.valueOf(response.getStatus()), detail);
        problem.setInstance(URI.create(request.getRequestURI()));
        response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
        jsonMapper.writeValue(response.getOutputStream(), problem);
    }
}

Two endpoints to call: GET /api/me returns the name and the sorted authorities of the Authentication, and GET /api/admin/report returns a fixed record behind hasRole("ADMIN").

resourceserver/src/main/java/com/example/resourceserver/account/MeController.java
package com.example.resourceserver.account;
 
import java.util.List;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
 
@RestController
public class MeController {
 
    public record MeResponse(String name, List<String> authorities) {
    }
 
    @GetMapping("/api/me")
    public MeResponse me(Authentication authentication) {
        List<String> authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .sorted()
                .toList();
        return new MeResponse(authentication.getName(), authorities);
    }
}
resourceserver/src/main/java/com/example/resourceserver/report/ReportController.java
package com.example.resourceserver.report;
 
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
 
@RestController
public class ReportController {
 
    public record SalesReport(String period, long orders) {
    }
 
    @GetMapping("/api/admin/report")
    public SalesReport report() {
        return new SalesReport("2026-09", 42);
    }
}

The client_credentials token of the previous section answered 200 with {"name":"reporting-service","authorities":["FACTOR_BEARER"]}. SCOPE_catalog.read is missing: with authorities-claim-name=roles, the converter reads roles instead of scope, not in addition to it. Three more observations from this pair of applications:

  • The decoder is lazy. The resource server started in 0.7 seconds while the authorization server was down, and the first request answered 500, logged as JwtDecoderInitializationException: Failed to lazily resolve the supplied JwtDecoder instance caused by I/O error on GET request for "http://localhost:8214/.well-known/openid-configuration": Connection refused. Once the authorization server was up, the next request fetched the discovery document and the JWKS and answered 200.
  • The issuer must be fixed. Without spring.security.oauth2.authorizationserver.issuer, the server derives the issuer from each request. A token requested from http://127.0.0.1:8214 carried "iss":"http://127.0.0.1:8214", and the resource server refused it with error_description="An error occurred while attempting to decode the Jwt: The iss claim is not valid".
  • After the property, a token requested through 127.0.0.1 carried "iss":"http://localhost:8214" and answered 200.
authserver/src/main/resources/application.properties
spring.security.oauth2.authorizationserver.issuer=http://localhost:8214 

Signing keys: generated, loaded from a file, rotated

Does the default key change on every restart?

Yes. Without a JWKSource bean, OAuth2AuthorizationServerJwtAutoConfiguration generates a 2048-bit RSA key pair with KeyPairGenerator at startup and gives it UUID.randomUUID() as its kid. The consequences depend on the resource server's JWKS cache, so the lab counted every JWKS request in the authorization server's Tomcat access log:

authserver/src/main/resources/application.properties
server.tomcat.accesslog.enabled=true
server.tomcat.accesslog.buffered=false
server.tomcat.accesslog.pattern=%t "%r" %s %{ms}Tms

The experiment, in order:

StepToken and kidResource server answerJWKS fetches
T1 issued, sent to the resource serverT1, 674aeefd-bd82-42f6-b270-5da151a333492001, with the discovery document
authorization server restarted, T1 sent againT12000
T2 issued by the restarted server, sentT2, f5b4b14a-fb75-41b0-8ea1-b8c83429922e2001
T1 sent againT14011
T1 sent 5 more times, then 3 times 31 seconds laterT1401 every time8

T1 survived the restart only because the resource server still held the old public key in its cache. The first token with the new kid made it refetch the JWKS, which replaced the cache, and from then on T1 was rejected:

Text
HTTP/1.1 401
WWW-Authenticate: Bearer realm="catalogue", error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Signed JWT rejected: Another algorithm expected, or no matching key(s) found", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:9214/.well-known/oauth-protected-resource"
Content-Type: application/problem+json
 
{"detail":"Valid credentials are required to access this resource.","instance":"/api/me","status":401,"title":"Unauthorized"}

The last row is how the resource server picks keys. NimbusJwtDecoder.JwkSetUriJwtDecoderBuilder in 7.1.1 wraps its SpringJWKSource in Nimbus's JWKSourceBuilder with refreshAheadCache(false), rateLimited(false) and Nimbus's own cache, whose DEFAULT_CACHE_TIME_TO_LIVE is 300000 ms. The key is looked up by the kid in the token header; a kid missing from the cache triggers a fetch, and because rate limiting is off, every request with an unknown kid triggers another one: 8 requests, 8 fetches. A client that keeps sending an old token after a key change costs the authorization server one JWKS request per call.

The restart lost more than the key. /oauth2/introspect, called by web-app for T2 before a restart, answered {"active": true, "sub": "reporting-service", …}; after the restart it answered {"active":false}, while the resource server kept accepting T2 from its cache. The in-memory authorization service forgets every token it issued, which the persistence section fixes.

A persistent key loaded from a file

Two key pairs, generated with openssl as in Basics 35 and named after the month they start signing:

Bash
mkdir -p keys
for k in key-2026-09 key-2026-12; do
  openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out keys/$k.pem
  openssl pkey -in keys/$k.pem -pubout -out keys/$k.pub.pem
done

The files stay outside src/main/resources, so the private keys never end up in the jar; production mounts them from a secret store. A properties record lists the keys, and a JWKSource bean turns them into Nimbus RSAKeys with Spring Security's RsaKeyConverters:

authserver/src/main/java/com/example/authserver/key/SigningKeyProperties.java
package com.example.authserver.key;
 
import java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.core.io.Resource;
 
@ConfigurationProperties("app.signing")
public record SigningKeyProperties(List<Key> keys) {
 
    public record Key(String id, Resource publicKey, Resource privateKey) {
    }
}
authserver/src/main/java/com/example/authserver/key/SigningKeyConfig.java
package com.example.authserver.key;
 
import com.nimbusds.jose.jwk.JWK;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.util.List;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.core.io.Resource;
import org.springframework.security.converter.RsaKeyConverters;
 
@Configuration
@EnableConfigurationProperties(SigningKeyProperties.class)
public class SigningKeyConfig {
 
    @Bean
    JWKSource<SecurityContext> jwkSource(SigningKeyProperties properties) {
        List<JWK> keys = properties.keys().stream().map(SigningKeyConfig::toJwk).toList();
        return new ImmutableJWKSet<>(new JWKSet(keys));
    }
 
    private static JWK toJwk(SigningKeyProperties.Key key) {
        RSAKey.Builder builder = new RSAKey.Builder(read(key.publicKey(), RsaKeyConverters.x509()))
                .keyID(key.id());
        if (key.privateKey() != null) {
            builder.privateKey(read(key.privateKey(), RsaKeyConverters.pkcs8()));
        }
        return builder.build();
    }
 
    private static <T> T read(Resource resource, Converter<InputStream, T> converter) {
        try (InputStream in = resource.getInputStream()) {
            return converter.convert(in);
        } catch (IOException ex) {
            throw new UncheckedIOException("Cannot read " + resource, ex);
        }
    }
}
authserver/src/main/resources/application.properties
app.signing.keys[0].id=key-2026-09
app.signing.keys[0].public-key=file:keys/key-2026-09.pub.pem
app.signing.keys[0].private-key=file:keys/key-2026-09.pem

file: paths resolve against the working directory, the project root in this lab. The private key is optional in the record because a retired key is published without it. Boot's generated JWKSource backs off as soon as this bean exists; the JWKS now listed one key with "kid": "key-2026-09", and tokens carried {"kid":"key-2026-09","alg":"RS256"}. A token T3 issued before restarting both servers answered 200 afterwards, with the resource server starting from an empty cache: the same key came back.

Key rotation with two keys in the JWKS

Rotation means publishing a new key, signing with it, and removing the old key once no valid token uses it. The first attempt put key-2026-12 in front of key-2026-09 in the list, both with private keys, and asked for a token:

Text
HTTP/1.1 500
{"timestamp":"2026-09-18T07:22:38.627Z","status":500,"error":"Internal Server Error","path":"/oauth2/token"}
Text
org.springframework.security.oauth2.jwt.JwtEncodingException: Failed to select a key since there are multiple for the signing algorithm [null]; please specify a selector in NimbusJwsEncoder#setJwkSelector

The same exception came back with key-2026-09 listed without its private key: in 7.1.1 the encoder counts public-only keys as candidates too. The fix the message asks for is a JwtEncoder bean with a selector, which the authorization server uses instead of creating its own NimbusJwtEncoder:

authserver/src/main/java/com/example/authserver/key/SigningKeyProperties.java
@ConfigurationProperties("app.signing")
public record SigningKeyProperties(List<Key> keys) { 
public record SigningKeyProperties(String activeKeyId, List<Key> keys) { 
authserver/src/main/java/com/example/authserver/key/SigningKeyConfig.java
import org.springframework.security.oauth2.jwt.JwtEncoder; 
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; 
 
    @Bean
    JwtEncoder jwtEncoder(JWKSource<SecurityContext> jwkSource, SigningKeyProperties properties) { 
        NimbusJwtEncoder encoder = new NimbusJwtEncoder(jwkSource); 
        encoder.setJwkSelector(candidates -> candidates.stream() 
                .filter(jwk -> jwk.getKeyID().equals(properties.activeKeyId())) 
                .findFirst() 
                .orElseThrow()); 
        return encoder; 
    } 
authserver/src/main/resources/application.properties
app.signing.active-key-id=key-2026-12
app.signing.keys[0].id=key-2026-12
app.signing.keys[0].public-key=file:keys/key-2026-12.pub.pem
app.signing.keys[0].private-key=file:keys/key-2026-12.pem
app.signing.keys[1].id=key-2026-09
app.signing.keys[1].public-key=file:keys/key-2026-09.pub.pem

The JWKS published both keys, without private parts: jq -c '.keys[] | {kid, has_private: has("d")}' printed {"kid":"key-2026-12","has_private":false} and {"kid":"key-2026-09","has_private":false}. For this experiment only, reporting-service tokens were given 15 minutes with --spring.security.oauth2.authorizationserver.client.reporting-service.token.access-token-time-to-live=15m, so that the old token could outlive the resource server's cache. The timeline, with the resource server running throughout:

TimeAuthorization serverResource serverToken A (key-2026-09)
14:23:40signs with key-2026-09; A issuedfetches discovery and JWKS200
14:23:55restarted: signs with key-2026-12, publishes both; B issuedB has an unknown kid: one JWKS fetch; B 200200
14:24:08restarted with key-2026-12 onlyno fetch200
14:28:48no fetch200
14:29:09cache older than 5 minutes: refetch, then another fetch for A's missing kid401

The polling loop printed the last two lines as:

Text
14:28:48 RS A: 200
14:29:09 RS A: 401

Removing a key from the authorization server takes effect at a resource server only when its cache expires, up to 5 minutes after its last fetch here, or when some token forces a refetch. B answered 200 throughout. The procedure that follows from these runs:

StepConfigurationWhy
1publish the new key without signing with itresource servers that do not refetch on an unknown kid learn it before the first token arrives
2set active-key-id to the new key; keep the old one public-onlytokens signed with the old key stay verifiable
3wait for the longest access token lifetime plus the resource servers' cache time5 minutes plus 5 minutes with the defaults above
4remove the old keyno valid token refers to it any more

The schema scripts shipped in the jar

The module ships three scripts:

Bash
unzip -l spring-security-oauth2-authorization-server-7.1.1.jar | grep '\.sql'
Text
      232  02-01-1980 00:00   org/springframework/security/oauth2/server/authorization/oauth2-authorization-consent-schema.sql
     2169  02-01-1980 00:00   org/springframework/security/oauth2/server/authorization/oauth2-authorization-schema.sql
     1122  02-01-1980 00:00   org/springframework/security/oauth2/server/authorization/client/oauth2-registered-client-schema.sql

Concatenated unchanged into a Flyway migration, they failed on PostgreSQL 18.6:

Text
o.f.core.internal.command.DbMigrate      : Migration of schema "public" to version "1 - oauth2 authorization server" failed! Changes successfully rolled back.
SQL State  : 42704
Error Code : 0
Message    : ERROR: type "blob" does not exist
  Position: 833
Location   : db/migration/V1__oauth2_authorization_server.sql (…)
Line       : 34

The scripts themselves say what to change, in a comment above two of the tables: blob columns become text, and timestamp columns become timestamptz so that instants are stored with their offset. Thirteen blob columns and fourteen timestamp columns changed, the comments went, and nothing else:

authserver/src/main/resources/db/migration/V1__oauth2_authorization_server.sql
CREATE TABLE oauth2_registered_client (
    id varchar(100) NOT NULL,
    client_id varchar(100) NOT NULL,
    client_id_issued_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,
    client_secret varchar(200) DEFAULT NULL,
    client_secret_expires_at timestamptz DEFAULT NULL,
    client_name varchar(200) NOT NULL,
    client_authentication_methods varchar(1000) NOT NULL,
    authorization_grant_types varchar(1000) NOT NULL,
    redirect_uris varchar(1000) DEFAULT NULL,
    post_logout_redirect_uris varchar(1000) DEFAULT NULL,
    scopes varchar(1000) NOT NULL,
    client_settings varchar(2000) NOT NULL,
    token_settings varchar(2000) NOT NULL,
    PRIMARY KEY (id)
);
CREATE TABLE oauth2_authorization (
    id varchar(100) NOT NULL,
    registered_client_id varchar(100) NOT NULL,
    principal_name varchar(200) NOT NULL,
    authorization_grant_type varchar(100) NOT NULL,
    authorized_scopes varchar(1000) DEFAULT NULL,
    attributes text DEFAULT NULL,
    state varchar(500) DEFAULT NULL,
    authorization_code_value text DEFAULT NULL,
    authorization_code_issued_at timestamptz DEFAULT NULL,
    authorization_code_expires_at timestamptz DEFAULT NULL,
    authorization_code_metadata text DEFAULT NULL,
    access_token_value text DEFAULT NULL,
    access_token_issued_at timestamptz DEFAULT NULL,
    access_token_expires_at timestamptz DEFAULT NULL,
    access_token_metadata text DEFAULT NULL,
    access_token_type varchar(100) DEFAULT NULL,
    access_token_scopes varchar(1000) DEFAULT NULL,
    oidc_id_token_value text DEFAULT NULL,
    oidc_id_token_issued_at timestamptz DEFAULT NULL,
    oidc_id_token_expires_at timestamptz DEFAULT NULL,
    oidc_id_token_metadata text DEFAULT NULL,
    refresh_token_value text DEFAULT NULL,
    refresh_token_issued_at timestamptz DEFAULT NULL,
    refresh_token_expires_at timestamptz DEFAULT NULL,
    refresh_token_metadata text DEFAULT NULL,
    user_code_value text DEFAULT NULL,
    user_code_issued_at timestamptz DEFAULT NULL,
    user_code_expires_at timestamptz DEFAULT NULL,
    user_code_metadata text DEFAULT NULL,
    device_code_value text DEFAULT NULL,
    device_code_issued_at timestamptz DEFAULT NULL,
    device_code_expires_at timestamptz DEFAULT NULL,
    device_code_metadata text DEFAULT NULL,
    PRIMARY KEY (id)
);
CREATE TABLE oauth2_authorization_consent (
    registered_client_id varchar(100) NOT NULL,
    principal_name varchar(200) NOT NULL,
    authorities varchar(1000) NOT NULL,
    PRIMARY KEY (registered_client_id, principal_name)
);

Users get a table of their own, the shape of Basics 34's cut down to what login needs. The two BCrypt hashes were made with PasswordEncoderFactories.createDelegatingPasswordEncoder(); seeding users from a migration is for the lab only.

authserver/src/main/resources/db/migration/V2__users.sql
CREATE TABLE users (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    username varchar(50) NOT NULL UNIQUE,
    password_hash varchar(100) NOT NULL,
    role varchar(20) NOT NULL,
    enabled boolean NOT NULL DEFAULT true
);
 
-- lab users: alice / Iua27v1PAG-AQ772BVq-TZts, admin / nCluGWX0moLiXhqciaN7dxit
INSERT INTO users (username, password_hash, role) VALUES
    ('alice', '{bcrypt}$2a$10$6W6XLlDl9PKCuXXsYJHhF.X8bha9O.LQ59z0k8oAp49IFp6VWWVlu', 'USER'),
    ('admin', '{bcrypt}$2a$10$Yd8AHFQiCn6rlM4UAwvMTeG8piD0fiziFRUdyAzQuE9ygX5YAVbpK', 'ADMIN');
Text
o.f.core.internal.command.DbMigrate      : Migrating schema "public" to version "1 - oauth2 authorization server"
o.f.core.internal.command.DbMigrate      : Migrating schema "public" to version "2 - users"
o.f.core.internal.command.DbMigrate      : Successfully applied 2 migrations to schema "public", now at version v2 (execution time 00:00.040s)

JdbcRegisteredClientRepository, JdbcOAuth2AuthorizationService, JdbcOAuth2AuthorizationConsentService

Three beans replace the in-memory defaults, and a PasswordEncoder bean hashes client secrets and checks them:

authserver/src/main/java/com/example/authserver/authorization/AuthorizationPersistenceConfig.java
package com.example.authserver.authorization;
 
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcOperations;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationConsentService;
import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
 
@Configuration
public class AuthorizationPersistenceConfig {
 
    @Bean
    RegisteredClientRepository registeredClientRepository(JdbcOperations jdbcOperations) {
        return new JdbcRegisteredClientRepository(jdbcOperations);
    }
 
    @Bean
    OAuth2AuthorizationService authorizationService(JdbcOperations jdbcOperations,
                                                    RegisteredClientRepository registeredClientRepository) {
        return new JdbcOAuth2AuthorizationService(jdbcOperations, registeredClientRepository);
    }
 
    @Bean
    OAuth2AuthorizationConsentService authorizationConsentService(JdbcOperations jdbcOperations,
                                                                  RegisteredClientRepository registeredClientRepository) {
        return new JdbcOAuth2AuthorizationConsentService(jdbcOperations, registeredClientRepository);
    }
 
    @Bean
    PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

With a RegisteredClientRepository bean in the context, the client.* properties are no longer read, so the clients move into code. save on the JDBC repository updates a client whose id already exists, which makes this runner an upsert on every start; a real server manages clients through an admin endpoint or a migration instead.

authserver/src/main/java/com/example/authserver/client/ClientRegistrations.java
package com.example.authserver.client;
 
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
import org.springframework.stereotype.Component;
 
@Component
public class ClientRegistrations implements ApplicationRunner {
 
    private final RegisteredClientRepository clients;
    private final PasswordEncoder passwordEncoder;
 
    public ClientRegistrations(RegisteredClientRepository clients, PasswordEncoder passwordEncoder) {
        this.clients = clients;
        this.passwordEncoder = passwordEncoder;
    }
 
    @Override
    public void run(ApplicationArguments args) {
        // lab secrets, generated with openssl rand for this lab only
        clients.save(RegisteredClient.withId("reporting-service")
                .clientId("reporting-service")
                .clientSecret(passwordEncoder.encode("YKEH82IfQ7fwVscNczMHPi-X"))
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
                .scope("catalog.read")
                .build());
        clients.save(RegisteredClient.withId("web-app")
                .clientId("web-app")
                .clientSecret(passwordEncoder.encode("qPzyIVmf35_MSv6B67GLD6-i"))
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                .redirectUri("http://127.0.0.1:10214/login/oauth2/code/web-app")
                .scope(OidcScopes.OPENID)
                .scope(OidcScopes.PROFILE)
                .scope("catalog.read")
                .scope("catalog.write")
                .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
                .build());
        clients.save(RegisteredClient.withId("spa")
                .clientId("spa")
                .clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .redirectUri("http://127.0.0.1:10214/spa/callback")
                .scope(OidcScopes.OPENID)
                .scope("catalog.read")
                .build());
    }
}

Users come from the table through a UserDetailsService, which also makes Boot drop its in-memory user. The properties lose both blocks, the nested registration lines of the three clients included, and keep the issuer:

authserver/src/main/resources/application.properties
spring.security.user.name=alice 
spring.security.user.password=Iua27v1PAG-AQ772BVq-TZts 
spring.security.oauth2.authorizationserver.client.reporting-service.registration.client-id=reporting-service 
# … every other spring.security.oauth2.authorizationserver.client.* line goes too 
spring.security.oauth2.authorizationserver.issuer=http://localhost:8214

JdbcClient is enough for the lookup, and Basics 34 covers the rest of the idea, BCrypt included.

authserver/src/main/java/com/example/authserver/user/DatabaseUserDetailsService.java
package com.example.authserver.user;
 
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
 
@Service
public class DatabaseUserDetailsService implements UserDetailsService {
 
    private final JdbcClient jdbcClient;
 
    public DatabaseUserDetailsService(JdbcClient jdbcClient) {
        this.jdbcClient = jdbcClient;
    }
 
    @Override
    public UserDetails loadUserByUsername(String username) {
        return jdbcClient.sql("select username, password_hash, role, enabled from users where username = ?")
                .param(username)
                .query((rs, rowNum) -> User.withUsername(rs.getString("username"))
                        .password(rs.getString("password_hash"))
                        .roles(rs.getString("role"))
                        .disabled(!rs.getBoolean("enabled"))
                        .build())
                .optional()
                .orElseThrow(() -> new UsernameNotFoundException(username));
    }
}

After a start, a client_credentials call and alice's authorization_code flow, the three tables in PostgreSQL (the Docker container sba-a14-pg) held:

Bash
docker exec sba-a14-pg psql -U demo -d demo -c "select id, client_id, left(client_secret, 20) as client_secret, authorization_grant_types from oauth2_registered_client order by id"
Text
        id         |     client_id     |    client_secret     |    authorization_grant_types
-------------------+-------------------+----------------------+----------------------------------
 reporting-service | reporting-service | {bcrypt}$2a$10$U30Ui | client_credentials
 spa               | spa               |                      | authorization_code
 web-app           | web-app           | {bcrypt}$2a$10$ZnQwD | refresh_token,authorization_code
(3 rows)
Text
 registered_client_id |  principal_name   | authorization_grant_type |      authorized_scopes      | code |    access_token_expires_at    |   refresh_token_expires_at    | id_token
----------------------+-------------------+--------------------------+-----------------------------+------+-------------------------------+-------------------------------+----------
 reporting-service    | reporting-service | client_credentials       | catalog.read                | f    | 2026-09-18 07:36:12.302952+00 |                               | f
 reporting-service    | reporting-service | client_credentials       | catalog.read                | f    | 2026-09-18 07:36:24.624077+00 |                               | f
 web-app              | alice             | authorization_code       | openid,profile,catalog.read | t    | 2026-09-18 07:36:32.760403+00 | 2026-09-18 08:31:32.765385+00 | t
(3 rows)
 
 registered_client_id | principal_name |                  authorities
----------------------+----------------+-----------------------------------------------
 web-app              | alice          | SCOPE_openid,SCOPE_catalog.read,SCOPE_profile
(1 row)
  • Every token request writes a row to oauth2_authorization, client_credentials included: one row per grant, holding the code, the access token, the refresh token and the ID token of that grant, with their metadata as JSON in the text columns.
  • Nothing deletes them. The public methods of JdbcOAuth2AuthorizationService are save, remove, findById and findByToken, and nothing in the module schedules a cleanup. By the end of the lab the table held 1014 rows, every one with an expired access token; 1000 of them came from the load test in the comparison section. A scheduled delete of rows whose tokens have all expired is yours to write.
  • The consent row stores the granted scopes as SCOPE_ authorities per client and user.

With the JDBC services, yes. Alice's access token from before a restart, introspected after it:

JSON
{
  "active": true,
  "sub": "alice",
  "aud": [
    "web-app"
  ],
  "nbf": 1789716692,
  "scope": "openid profile catalog.read",
  "iss": "http://localhost:8214",
  "exp": 1789716992,
  "iat": 1789716692,
  "jti": "66e65286-fc71-4cc2-8447-e16f3e60d91e",
  "client_id": "web-app",
  "token_type": "Bearer"
}

The in-memory service had answered {"active":false} in the same situation. Alice's next login after the restart skipped the consent page: the request replayed after hop 3 answered 302 straight to the redirect URI with a code, because the consent row was still there. The login itself still went through the form, since the login session lives in the HttpSession, not in these tables; several instances of the server behind a load balancer need Spring Session or sticky sessions for that part.

The components wired into the authorization server and what backs each: the registered client repository and the oauth2_registered_client table, the authorization service and oauth2_authorization, the consent service and oauth2_authorization_consent, the JWK source and the key files, the JWT encoder with its key selector, the token customizer, and the user details service with the users table

Adding a roles claim with OAuth2TokenCustomizer

The resource server maps a roles claim to ROLE_ authorities, as in Basics 35, and the tokens have no such claim yet. An OAuth2TokenCustomizer<JwtEncodingContext> bean is called for every JWT the server encodes, access tokens and ID tokens alike:

authserver/src/main/java/com/example/authserver/token/RolesClaimCustomizer.java
package com.example.authserver.token;
 
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext;
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
import org.springframework.stereotype.Component;
 
@Component
public class RolesClaimCustomizer implements OAuth2TokenCustomizer<JwtEncodingContext> {
 
    @Override
    public void customize(JwtEncodingContext context) {
        if (!OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())
                || AuthorizationGrantType.CLIENT_CREDENTIALS.equals(context.getAuthorizationGrantType())) {
            return;
        }
        List<String> roles = context.getPrincipal().getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .filter(authority -> authority.startsWith("ROLE_"))
                .map(authority -> authority.substring("ROLE_".length()))
                .collect(Collectors.toCollection(ArrayList::new));
        context.getClaims().claim("roles", roles);
    }
}

A temporary log line in an earlier version printed what getPrincipal() holds for each grant:

Text
LAB principal=OAuth2ClientAuthenticationToken authorities=[] grant=client_credentials
LAB principal=UsernamePasswordAuthenticationToken authorities=[ROLE_ADMIN, FactorGrantedAuthority [authority=FACTOR_PASSWORD, issuedAt=2026-09-18T07:32:14.259124Z]] grant=authorization_code
  • For authorization_code the principal is the user's login: ROLE_ADMIN plus the FACTOR_PASSWORD of Spring Security 7, which the ROLE_ filter keeps out of the claim.
  • For client_credentials it is the client, with no authorities. That earlier version, without the grant check, wrote "roles":[] into every service token; with the check, the service token keeps its standard claims only.
  • The ID token is skipped by the first condition, and admin's ID token came out with the same claims as alice's in the flow section.

admin's access token after the flow, decoded:

JSON
{
  "kid": "key-2026-12",
  "alg": "RS256"
}
JSON
{
  "sub": "admin",
  "aud": "web-app",
  "nbf": 1789716758,
  "scope": [
    "openid",
    "profile",
    "catalog.read"
  ],
  "roles": [
    "ADMIN"
  ],
  "iss": "http://localhost:8214",
  "exp": 1789717058,
  "iat": 1789716758,
  "jti": "037dc0ef-f8c2-46aa-807c-767e486f3234"
}

On the resource server, with Basics 35's two properties and no code change:

Text
== admin /api/me
{"name":"admin","authorities":["FACTOR_BEARER","ROLE_ADMIN"]}
== admin /api/admin/report
HTTP/1.1 200
{"period":"2026-09","orders":42}
== alice /api/me
{"name":"alice","authorities":["FACTOR_BEARER","ROLE_USER"]}
== alice /api/admin/report
HTTP/1.1 403
WWW-Authenticate: Bearer realm="catalogue", error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
Content-Type: application/problem+json
Content-Length: 123
 
{"detail":"You are not allowed to perform this operation.","instance":"/api/admin/report","status":403,"title":"Forbidden"}

admin's decoded access token: the header with kid key-2026-12 chosen by the key selector and alg RS256, the standard claims sub, aud, nbf, iat, exp, iss, jti and scope written by the authorization server, the roles claim written by RolesClaimCustomizer, and the ROLE_ADMIN and FACTOR_BEARER authorities the resource server derives

Why .toList() breaks a JDBC authorization server

⚠️ The first version of the customizer ended with .toList(). Tokens were issued and accepted, and every later read of those rows failed: the second use of a code, and introspection of both the access and the refresh token, all answered 500:

Text
java.lang.IllegalArgumentException: Could not resolve type id 'java.util.ImmutableCollections$ListN' as a subtype of `java.lang.Object`: Configured `PolymorphicTypeValidator` (of type `tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator`) denied resolution
 at [Source: REDACTED (`StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION` disabled); byte offset: #UNKNOWN] (through reference chain: java.util.LinkedHashMap["metadata.token.claims"]->java.util.LinkedHashMap["roles"])
	at org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService$AbstractOAuth2AuthorizationRowMapper.parseMap(JdbcOAuth2AuthorizationService.java:723)

JdbcOAuth2AuthorizationService stores the token claims as JSON with the Java type of each value, and reads them back through an allowlist. The stored metadata showed the difference between the two collections:

Bash
docker exec sba-a14-pg psql -U demo -d demo -At -c "select access_token_metadata from oauth2_authorization where principal_name='alice' order by access_token_issued_at desc limit 1" | jq -c '.["metadata.token.claims"].roles, .["metadata.token.claims"].scope'
Text
["java.util.ImmutableCollections$ListN",["USER"]]
["java.util.Collections$UnmodifiableSet",["openid","catalog.read"]]

Stream.toList() returns a JDK-internal class that is not on the list. The fix is the last line of the stream:

authserver/src/main/java/com/example/authserver/token/RolesClaimCustomizer.java
                .map(authority -> authority.substring("ROLE_".length()))
                .toList(); 
                .collect(Collectors.toCollection(ArrayList::new)); 
        context.getClaims().claim("roles", roles);

With Collectors.toCollection(ArrayList::new) the row stored ["java.util.ArrayList",["USER"]], introspection answered {"active":true,"roles":["USER"]}, and the reused code got its proper 400 invalid_grant. The in-memory service never serializes anything, so the bug appears only once the JDBC service is in place, and only after the token has been issued.

Opaque tokens and introspection

Switching a client to reference tokens

OAuth2TokenFormat.REFERENCE makes the server issue a random string and keep the claims in oauth2_authorization. A resource server has to ask for them at /oauth2/introspect, so it gets a registered client of its own:

authserver/src/main/java/com/example/authserver/client/ClientRegistrations.java
import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat; 
import org.springframework.security.oauth2.server.authorization.settings.TokenSettings; 
 
        clients.save(RegisteredClient.withId("reporting-service")
                .clientId("reporting-service")
                .clientSecret(passwordEncoder.encode("YKEH82IfQ7fwVscNczMHPi-X"))
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
                .scope("catalog.read")
                .tokenSettings(TokenSettings.builder().accessTokenFormat(OAuth2TokenFormat.REFERENCE).build()) 
                .build());
 
        clients.save(RegisteredClient.withId("resource-server") 
                .clientId("resource-server") 
                .clientSecret(passwordEncoder.encode("XYs4XlGxsLbXcZ99uD9CdCYa")) 
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) 
                .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) 
                .build()); 
Text
{"access_token":"FAsDhjb1vJGSCrsCsDCcWZIYZH5Itn0UgcO94fg21Eqgu5YguvtfWdq-6pe_J2Yr5VDkOytxdg6RL_okoE_68XYGoYwZvGG4k25bLjYuT_WcHoI0GSzZ2P5jIGXdcxA6","scope":"catalog.read","token_type":"Bearer","expires_in":299}

128 characters with nothing to decode. What the resource server learns about it:

Bash
curl -s -u resource-server:XYs4XlGxsLbXcZ99uD9CdCYa --data-urlencode "token=$OPAQUE" http://localhost:8214/oauth2/introspect | jq .
JSON
{
  "active": true,
  "sub": "reporting-service",
  "aud": [
    "reporting-service"
  ],
  "nbf": 1789716917,
  "scope": "catalog.read",
  "iss": "http://localhost:8214",
  "exp": 1789717217,
  "iat": 1789716917,
  "jti": "a0dfd384-8fb7-48a3-8264-c574a1afbe5d",
  "client_id": "reporting-service",
  "token_type": "Bearer"
}

Any registered client may introspect any token: web-app introspected reporting-service's tokens earlier in this article without complaint. The RolesClaimCustomizer is not called for reference tokens, whose claims go through an OAuth2TokenCustomizer<OAuth2TokenClaimsContext> instead; this client has no roles, so nothing was lost here.

A second resource server configuration pointed at /oauth2/introspect

The resource server gets a Spring profile opaque, with Boot's three introspection properties and a chain that uses opaqueToken instead of jwt; the JWT chain gets @Profile("!opaque").

resourceserver/src/main/resources/application-opaque.properties
spring.security.oauth2.resourceserver.opaquetoken.introspection-uri=http://localhost:8214/oauth2/introspect
spring.security.oauth2.resourceserver.opaquetoken.client-id=resource-server
spring.security.oauth2.resourceserver.opaquetoken.client-secret=XYs4XlGxsLbXcZ99uD9CdCYa
resourceserver/src/main/java/com/example/resourceserver/common/SecurityConfig.java
import org.springframework.context.annotation.Profile; 
 
@Configuration
@Profile("!opaque") 
public class SecurityConfig {
resourceserver/src/main/java/com/example/resourceserver/common/OpaqueTokenSecurityConfig.java
package com.example.resourceserver.common;
 
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
 
@Configuration
@Profile("opaque")
public class OpaqueTokenSecurityConfig {
 
    @Bean
    SecurityFilterChain apiSecurityFilterChain(HttpSecurity http, ProblemDetailSecurityHandler problemHandler) {
        http
                .securityMatcher("/api/**")
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/api/admin/**").hasRole("ADMIN")
                        .anyRequest().authenticated())
                .oauth2ResourceServer(oauth2 -> oauth2
                        .opaqueToken(Customizer.withDefaults())
                        .authenticationEntryPoint(problemHandler)
                        .accessDeniedHandler(problemHandler))
                .exceptionHandling(exceptions -> exceptions
                        .authenticationEntryPoint(problemHandler)
                        .accessDeniedHandler(problemHandler))
                .csrf(csrf -> csrf.disable())
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
        return http.build();
    }
}

Started with --spring.profiles.active=opaque, the resource server answered {"name":"reporting-service","authorities":["FACTOR_BEARER","SCOPE_catalog.read"]} for the opaque token. The authorities-claim-name and authority-prefix properties belong to the JWT converter; the default introspector maps scope to SCOPE_ authorities.

What does introspection cost per request?

The same GET /api/me, 100 warm-up requests and then 500 timed with curl's %{time_total}, one at a time, with the authorization server's access log counting what reached it; then ab -k with 8 concurrent connections for throughput:

Resource serverMinMedianp90MaxCalls to the authorization serverab -c 8Load (1 min)
JWT, JWKS cached0.43 ms0.68 ms0.99 ms3.98 ms1 JWKS refresh in 600 requests, 0 introspections22,803 req/s6.06 → 5.97
Opaque, introspection61.88 ms64.55 ms66.72 ms110.06 ms600 introspections for 600 requests99 req/s6.10 → 4.77

The access log during the opaque run held exactly 600 lines, all POST /oauth2/introspect, with a server-side median of 61 ms. The only call during the JWT run was the resource server's JWKS cache expiring after 5 minutes. Nearly all of those 61 ms are one check of resource-server's secret, stored as a BCrypt hash: a cost-10 BCryptPasswordEncoder.matches on this machine took a median of 57.5 ms over 30 calls (load 4.01), and the database lookup of the token is the small remainder.

An attempt to prove it by storing the secret as {noop} directly in the table failed in an instructive way: the next introspection authenticated, and the row then held {bcrypt}$2a$10$… again. ClientSecretAuthenticationProvider calls PasswordEncoder.upgradeEncoding after a successful check and saves the client with a re-encoded secret, the client-side counterpart of the password upgrade Basics 34 showed with UserDetailsPasswordService. The options, none of them free: cache introspection results in the resource server for a few seconds (the default SpringOpaqueTokenIntrospector caches nothing, so revocation stays immediate), authenticate the resource server with private_key_jwt instead of a hashed secret, or accept JWTs, which were two orders of magnitude cheaper here and cannot be recalled.

Spring Authorization Server or Keycloak?

Startup time and memory, measured

Keycloak 26.7.4 ran with start-dev in a container limited to 1536 MiB (docker run --memory 1536m -p 8314:8080 … quay.io/keycloak/keycloak:26.7.4 start-dev), with the bootstrap admin from environment variables and nothing else configured; article 13 covers its setup. The Spring Authorization Server was this article's final application, JDBC services included, started with java -Xmx512m -jar. Startup is the wall time from launching the process or the container until the discovery document first answered 200, polled every 50 ms for Spring and every 100 ms for Keycloak. Memory is the resident set size of the JVM (ps -o rss for Spring, VmRSS of the Java process inside the container for Keycloak) plus docker stats for the container, read 3 seconds after startup and after 1000 client_credentials token requests with ab -c 8. For Keycloak the client was created through its admin REST API.

Spring Authorization Server 7.1.1Keycloak 26.7.4 start-dev
Start to first 200 on discovery1.55 s, 1.58 s, 1.76 s11.7 s and 12.7 s for a new container, 3.9 s for a restart
Its own startup log lineStarted AuthserverApplication in 1.246, 1.227, 1.412 secondsstarted in 6.771s and 7.344s, 3.499s on restart
Memory after startupRSS 205 to 210 MiBJVM 581 and 592 MiB; container 569 and 580 MiB
After 1000 token requestsRSS 216 MiBJVM 636 MiB; container 624 MiB
Token requests per second, -c 8102 (each pays a 57.5 ms BCrypt check)912
Heap setting-Xmx512mthe image's -XX:MaxRAMPercentage=70, about 1075 MiB of the 1536 MiB limit
Load average (1 min)3.66 to 4.933.36 to 5.13

The comparison is not symmetric, and the table says how: Keycloak's figures include its embedded dev database and caches, while the Spring server kept its data in a separate PostgreSQL container that used 51 MiB. A new Keycloak container spent its first seconds on "Updating the configuration and installing your custom providers, if any", the build step that a production image runs ahead of time with kc.sh build.

What you get and what you build

Spring Authorization ServerKeycloak
Shapea library inside your Spring Boot applicationa separate server you run next to your applications
Admin consolenone: clients came from properties, then from codeweb admin console and admin REST API
Userswhatever your UserDetailsService or AuthenticationProvider readsits own user store, registration, password reset, account console
User federationyours to writeLDAP and Active Directory, Kerberos, a User Storage SPI
Social login and brokeringwire oauth2Login into the login chain yourselfGoogle, GitHub and other identity providers built in (article 13)
MFAbuilding blocks in Spring Security 7 (@EnableMultiFactorAuthentication, the FACTOR_ authorities seen above); screens and enrolment are yoursOTP and WebAuthn built in
ProtocolsOAuth 2.1 and OpenID ConnectOAuth 2.0, OpenID Connect and SAML 2.0
Custom claimsa customizer bean, unit-testable like any Spring beanprotocol mappers in the console, or a Java SPI provider
Login and consent pagesdefaults for development (the consent page loads Bootstrap from a CDN); you write the real onesthemeable pages with FreeMarker
Persistencethree tables from the jar, adapted by hand for PostgreSQL; expired rows are never deletedits own schema, migrated automatically when a new version starts
Upgradeswith Spring Boot and Spring Security, in your builda new image; the database is migrated on start
Clusteringstateless apart from the login HttpSession: a shared database plus Spring Session or sticky sessionsseveral nodes sharing the database, with embedded Infinispan caches
Footprint here1.6 s, 216 MiB11.7 s, 636 MiB

Which one to choose

  • Keycloak when people log in: self-registration, password resets, an admin console for support staff, LDAP or Active Directory, social login, MFA, SAML for older applications. On Spring Authorization Server each of those is code you write, test and maintain; Keycloak ships them.
  • Spring Authorization Server when the server is mostly for machines, or when tokens must be shaped by your own data: client_credentials between services, claims computed from your own tables, a login that is part of an existing Spring application with an existing users table, one deployment pipeline for everything, and a team that prefers Java code and tests to console configuration.
  • Either for the resource servers: both publish a discovery document and a JWKS, so a resource server moves from one to the other by changing issuer-uri and the claim its converter reads roles from.
  • A hosted identity provider when you want neither the code of the first nor the operations of the second.

FAQ

Is Spring Authorization Server part of Spring Security now?

Yes. In Spring Boot 4.1.1, spring-security-oauth2-authorization-server 7.1.1 comes from spring-security-bom 7.1.1 and has no version of its own; the classes stay in org.springframework.security.oauth2.server.authorization, and the configurer moved to spring-security-config as HttpSecurity.oauth2AuthorizationServer(...). Use spring-boot-starter-security-oauth2-authorization-server; spring-boot-starter-oauth2-authorization-server is deprecated in its favour.

Where did OAuth2AuthorizationServerConfiguration.applyDefaultSecurity go?

It is gone in 7.1.1. OAuth2AuthorizationServerConfiguration now lives in org.springframework.security.config.annotation.web.configuration without that method. Write http.oauth2AuthorizationServer(as -> { http.securityMatcher(as.getEndpointsMatcher()); as.oidc(Customizer.withDefaults()); }), as Boot's own OAuth2AuthorizationServerWebSecurityConfiguration does, and keep the form-login chain after it with a higher @Order value.

Why is my Spring Authorization Server token rejected after a restart?

Because the default JWKSource generates a new RSA key with a random kid at every start. Old tokens keep working only while a resource server still caches the old key; the first token with the new kid made the resource server refetch the JWKS, and the old token then got 401 with Signed JWT rejected: Another algorithm expected, or no matching key(s) found. Load the key from a file with a JWKSource bean.

How do I rotate signing keys in Spring Authorization Server?

Publish both keys in the JWKSource and add a JwtEncoder bean whose setJwkSelector picks the active kid. Without the selector, two keys, even with the old one public-only, gave Failed to select a key since there are multiple for the signing algorithm [null]. After removing the old key, a resource server kept accepting its tokens until its JWKS cache expired, about 5 minutes after its last fetch.

Which Spring Authorization Server schema changes does PostgreSQL need?

blob to text, which is mandatory (ERROR: type "blob" does not exist otherwise), and timestamp to timestamptz, which the scripts recommend. That is thirteen and fourteen columns across oauth2_authorization and oauth2_registered_client; the consent table needs nothing.

Why does introspection return 500 after adding a custom claim?

Because the claim's value has a type that JdbcOAuth2AuthorizationService's Jackson allowlist refuses to read back, such as the list Stream.toList() returns (java.util.ImmutableCollections$ListN). Build the value as an ArrayList, or another type the allowlist accepts, before putting it into the claims.

Are opaque tokens slower than JWTs?

Here, by two orders of magnitude: a median of 64.55 ms per request against 0.68 ms, and 99 against 22,803 requests per second with 8 connections, because every request made one introspection call and each call paid a 57.5 ms BCrypt check of the resource server's secret. In exchange, an opaque token stops working as soon as its authorization is invalidated.

Conclusion

A working authorization server took one properties file: three registered clients, a user, the discovery documents, the two filter chains and the protocol endpoints came from Boot's defaults in Spring Security 7.1.1. Owning it took more code, and each default that was replaced showed why it has to be: the RSA key changes on every restart and old tokens then survive only in resource server caches, two keys need a selector before the encoder can sign, the schema scripts need text and timestamptz on PostgreSQL, expired rows stay forever, and a custom claim built with .toList() makes every later read of its row fail. The traps outside the server were the unset issuer, the /error dispatch that sends a bad redirect URI to the login page, and a JWT resource server that kept accepting a token its authorization server had already invalidated.

Keycloak starts slower, uses about three times the memory and hands you a console, user management, federation and MFA; Spring Authorization Server starts in under two seconds and hands you Java code. The next article stays with tokens and authorization: refresh tokens and their rotation, token revocation, and permission-based authorization.

Related Posts

[Advanced Spring Boot] Spring Transactions in Depth: Propagation, Isolation Levels and Rollback Rules

Spring transaction propagation, isolation and rollback rules on Spring Boot 4.1.1 with PostgreSQL: all seven propagations with JpaTransactionManager logs and backend pids, the REQUIRES_NEW connection pool deadlock with HikariCP timings, why NESTED fails with JpaTransactionManager and works with JdbcTransactionManager savepoints, non-repeatable reads, lost updates and write skew under each isolation level, SQLSTATE 40001 as CannotAcquireLockException, a correct retry around the transaction, readOnly at the JDBC, PostgreSQL and Hibernate layers, validateExistingTransaction, rollbackOn ALL_EXCEPTIONS and what really enforces @Transactional(timeout).

[Advanced Spring Boot] NoSQL with Spring Data: MongoDB and Redis

Spring Data MongoDB and Spring Data Redis on Spring Boot 4.1.1: @Document, String vs ObjectId ids, the _class field, embedded vs @DocumentReference with the queries each sends, MongoRepository and MongoTemplate with the logged commands, $push/$inc vs load-modify-save under 50 threads, @Version, whether Boot creates @Indexed indexes, COLLSCAN vs IXSCAN on 300,000 documents, an aggregation pipeline, transactions on a replica set, a renamed field, RedisTemplate serialization, INCR, sorted sets, hashes, TTL, @RedisHash keys and phantom keys, pipelining and Lettuce.

[Advanced Spring Boot] Writing Your Own Auto-configuration and Starter

Build and ship a real Spring Boot 4.1.1 starter: the three Gradle projects and the x-spring-boot-starter naming rule, an @AutoConfiguration class with @ConditionalOnMissingBean and @ConditionalOnProperty, registration in AutoConfiguration.imports, ordering with before/after against a Boot auto-configuration, a validated @ConfigurationProperties record with generated spring-configuration-metadata.json, a custom SpringBootCondition with its ConditionOutcome message in the report, five ApplicationContextRunner tests including FilteredClassLoader, publishing to mavenLocal and consuming it, and a FailureAnalyzer for the misconfiguration.

[Advanced Spring Boot] OAuth2 and OpenID Connect in Spring Boot: OAuth2 Login and a JWT Resource Server

OAuth2 and OpenID Connect with Spring Security on Spring Boot 4.1.1 and Keycloak: a realm imported from JSON, issuer-uri discovery and the startup failure when Keycloak is down, the authorization code flow with PKCE (S256, on by default for a confidential client) hop by hop, a stolen code rejected without its verifier, the ID token and the access token decoded side by side, the OidcUser with its OIDC_USER and SCOPE_ authorities, user-name-attribute, Keycloak realm roles mapped to ROLE_ with a GrantedAuthoritiesMapper, RP-initiated logout with OidcClientInitiatedLogoutSuccessHandler, Google and GitHub through CommonOAuth2Provider, a JWT resource server with issuer-uri and lazily fetched keys, the WWW-Authenticate headers for foreign-realm, wrong-issuer, tampered, wrong-audience and expired tokens, realm_access.roles mapped with authorities-claim-expressions, token relay with OAuth2ClientHttpRequestInterceptor and a client-credentials token reused across calls.