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.
![]()
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:
| Artifact | Description in its pom | What it contains |
|---|---|---|
spring-boot-starter-security-oauth2-authorization-server | Starter for using Spring Authorization Server features | the dependencies below; this is what Initializr writes |
spring-boot-starter-oauth2-authorization-server | Starter 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:
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.zipdependencies {
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'
}./gradlew dependencies --configuration runtimeClasspath+--- 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-serveris Boot's auto-configuration:OAuth2AuthorizationServerAutoConfigurationandOAuth2AuthorizationServerJwtAutoConfiguration, plus the properties below.spring-security-oauth2-resource-serveris 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.
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.readserver:
port: 8214
spring:
security:
user:
name: alice
password: Iua27v1PAG-AQ772BVq-TZts
oauth2:
authorizationserver:
client:
reporting-service:
registration:
client-id: reporting-service
client-secret: "{noop}YKEH82IfQ7fwVscNczMHPi-X"
client-authentication-methods: client_secret_basic
authorization-grant-types: client_credentials
scopes: catalog.read
web-app:
registration:
client-id: web-app
client-secret: "{noop}qPzyIVmf35_MSv6B67GLD6-i"
client-authentication-methods: client_secret_basic
authorization-grant-types: authorization_code,refresh_token
redirect-uris: http://127.0.0.1:10214/login/oauth2/code/web-app
scopes: openid,profile,catalog.read,catalog.write
require-authorization-consent: true
spa:
registration:
client-id: spa
client-authentication-methods: none
authorization-grant-types: authorization_code
redirect-uris: http://127.0.0.1:10214/spa/callback
scopes: openid,catalog.readThe 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. | Default | Notes |
|---|---|---|
issuer | none | resolved from each request when unset; the resource server section shows why to set it |
client.<key>.registration.client-id, client-secret, client-name | none | <key> is only the map key |
client.<key>.registration.client-authentication-methods, authorization-grant-types, redirect-uris, post-logout-redirect-uris, scopes | none | comma-separated sets |
client.<key>.require-proof-key | true | PKCE for every client, confidential ones included |
client.<key>.require-authorization-consent | false | true shows the consent page |
client.<key>.jwk-set-uri, token-endpoint-authentication-signing-algorithm | none | for private_key_jwt and client_secret_jwt clients |
client.<key>.token.access-token-time-to-live | 5 minutes | authorization-code-time-to-live and device-code-time-to-live are also 5 minutes |
client.<key>.token.access-token-format | self-contained | reference issues opaque tokens |
client.<key>.token.refresh-token-time-to-live, reuse-refresh-tokens | 60 minutes, true | article 15 |
client.<key>.token.id-token-signature-algorithm | RS256 | |
endpoint.* | /oauth2/authorize, /oauth2/token, … | one property per endpoint path |
multiple-issuers-allowed | none | several 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):
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):
curl -s http://localhost:8214/.well-known/openid-configuration | jq .{
"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.
| Endpoint | Advertised as | Who calls it |
|---|---|---|
/oauth2/authorize | authorization_endpoint | the browser, in the authorization_code flow |
/oauth2/token | token_endpoint | every client, for every grant |
/oauth2/jwks | jwks_uri | resource servers that verify JWTs |
/oauth2/introspect | introspection_endpoint | resource servers that receive opaque tokens |
/oauth2/revoke | revocation_endpoint | clients (article 15) |
/userinfo | userinfo_endpoint, OpenID document only | clients holding an access token with openid |
/connect/logout | end_session_endpoint, OpenID document only | RP-initiated logout |
/oauth2/device_authorization, /oauth2/device_verification | not advertised | not enabled |
/oauth2/par | not advertised | not enabled |
/connect/register, /oauth2/register | not advertised | not 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 two filter chains Spring Boot sets up
With logging.level.org.springframework.security.web.DefaultSecurityFilterChain=DEBUG, the startup log lists two chains:
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, AuthorizationFilterBoth 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 throughgetEndpointsMatcher(). 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/loginpage 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:
curl -i -s "$AUTH_URL"
curl -i -s -H 'Accept: text/html' "$AUTH_URL"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: 0curl'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:
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:
| Change | What happened |
|---|---|
@Order values swapped | startup failed with UnreachableFilterChainException (quoted below) |
both @Order annotations removed | worked, because the beans kept their declaration order; do not rely on it |
exceptionHandling(...) removed | the 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(...) removed | OidcLogoutEndpointFilter, 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(...) removed | nothing 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 […]:
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 requestRunning the flows with curl
client_credentials: a token for a service
curl -i -s -u reporting-service:YKEH82IfQ7fwVscNczMHPi-X -d grant_type=client_credentials -d scope=catalog.read http://localhost:8214/oauth2/tokenHTTP/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:
{
"kid": "2673107f-e528-4d61-9214-e74016984d8b",
"alg": "RS256"
}{
"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"
}kidis a random UUID, not the RFC 7638 thumbprint of Basics 35, and the header has notyp.subandaudare both the client id: there is no user in this grant.scopeis a JSON array in the token and a space-separated string in the response. Basics 35'sJwtGrantedAuthoritiesConverterreads either form.exp−iat= 300: the default access token lives 5 minutes;expires_insaid 299 because a second had started.jtigives every token its own id.
The key behind the kid, from jwks_uri:
curl -s http://localhost:8214/oauth2/jwks | jq .{
"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:
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"verifier=MomTxxPZjSh15C6IONsnNzDCk5eqo2gAGspUS95oKi8 challenge=If9Ga0pCXRVHixVYqJJHGCC6hsjkIEkQzwof5bIsZckHop 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:
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/loginHTTP/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&continueThe 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:
<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:
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/authorizeHTTP/1.1 302
Location: http://127.0.0.1:10214/login/oauth2/code/web-app?code=k2yC0IY8_3dDLktaDiSfe4FnkoCHgaXFB6aWKU-v5w7OKeU25PPVkpmsLuCrX6iFbIZBatgnF_M4fmsWfBwcnG9d9xtVcmW_5ey_tp_IgUxL1YqhkQjXhdygrNP6JmjB&state=af0ifjsldkjHop 6, the token exchange, from the client's back end, with the secret and the verifier:
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/tokenHTTP/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:
{
"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 (exp − iat = 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:
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:
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"HTTP/1.1 302
Set-Cookie: JSESSIONID=0D1C82FC1C1A6072CE68E5382738BA8D; Path=/; HttpOnly
Location: http://localhost:8214/login;jsessionid=0D1C82FC1C1A6072CE68E5382738BA8DA 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.
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=D4373069EB50645E355F81CA69B3FB2EIt 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:
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:
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"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=s2code_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:
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.
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_server:
port: 9214
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8214
authorities-claim-name: roles
authority-prefix: ROLE_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:
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").
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);
}
}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 asJwtDecoderInitializationException: Failed to lazily resolve the supplied JwtDecoder instancecaused byI/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 answered200. - The issuer must be fixed. Without
spring.security.oauth2.authorizationserver.issuer, the server derives the issuer from each request. A token requested fromhttp://127.0.0.1:8214carried"iss":"http://127.0.0.1:8214", and the resource server refused it witherror_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.1carried"iss":"http://localhost:8214"and answered200.
spring.security.oauth2.authorizationserver.issuer=http://localhost:8214 spring:
security:
oauth2:
authorizationserver:
issuer: http://localhost:8214Signing 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:
server.tomcat.accesslog.enabled=true
server.tomcat.accesslog.buffered=false
server.tomcat.accesslog.pattern=%t "%r" %s %{ms}Tmsserver:
tomcat:
accesslog:
enabled: true
buffered: false
pattern: '%t "%r" %s %{ms}Tms'The experiment, in order:
| Step | Token and kid | Resource server answer | JWKS fetches |
|---|---|---|---|
| T1 issued, sent to the resource server | T1, 674aeefd-bd82-42f6-b270-5da151a33349 | 200 | 1, with the discovery document |
| authorization server restarted, T1 sent again | T1 | 200 | 0 |
| T2 issued by the restarted server, sent | T2, f5b4b14a-fb75-41b0-8ea1-b8c83429922e | 200 | 1 |
| T1 sent again | T1 | 401 | 1 |
| T1 sent 5 more times, then 3 times 31 seconds later | T1 | 401 every time | 8 |
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:
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:
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
doneThe 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:
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) {
}
}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);
}
}
}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.pemapp:
signing:
keys:
- id: key-2026-09
public-key: file:keys/key-2026-09.pub.pem
private-key: file:keys/key-2026-09.pemfile: 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:
HTTP/1.1 500
{"timestamp":"2026-09-18T07:22:38.627Z","status":500,"error":"Internal Server Error","path":"/oauth2/token"}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#setJwkSelectorThe 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:
@ConfigurationProperties("app.signing")
public record SigningKeyProperties(List<Key> keys) {
public record SigningKeyProperties(String activeKeyId, List<Key> keys) { 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;
} 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.pemapp:
signing:
active-key-id: key-2026-12
keys:
- id: key-2026-12
public-key: file:keys/key-2026-12.pub.pem
private-key: file:keys/key-2026-12.pem
- id: key-2026-09
public-key: file:keys/key-2026-09.pub.pemThe 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:
| Time | Authorization server | Resource server | Token A (key-2026-09) |
|---|---|---|---|
| 14:23:40 | signs with key-2026-09; A issued | fetches discovery and JWKS | 200 |
| 14:23:55 | restarted: signs with key-2026-12, publishes both; B issued | B has an unknown kid: one JWKS fetch; B 200 | 200 |
| 14:24:08 | restarted with key-2026-12 only | no fetch | 200 |
| 14:28:48 | no fetch | 200 | |
| 14:29:09 | cache older than 5 minutes: refetch, then another fetch for A's missing kid | 401 |
The polling loop printed the last two lines as:
14:28:48 RS A: 200
14:29:09 RS A: 401Removing 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:
| Step | Configuration | Why |
|---|---|---|
| 1 | publish the new key without signing with it | resource servers that do not refetch on an unknown kid learn it before the first token arrives |
| 2 | set active-key-id to the new key; keep the old one public-only | tokens signed with the old key stay verifiable |
| 3 | wait for the longest access token lifetime plus the resource servers' cache time | 5 minutes plus 5 minutes with the defaults above |
| 4 | remove the old key | no valid token refers to it any more |
Persisting clients, authorizations and consent in PostgreSQL
The schema scripts shipped in the jar
The module ships three scripts:
unzip -l spring-security-oauth2-authorization-server-7.1.1.jar | grep '\.sql' 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.sqlConcatenated unchanged into a Flyway migration, they failed on PostgreSQL 18.6:
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 : 34The 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:
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.
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');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:
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.
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:
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:8214spring:
security:
user:
name: alice
password: Iua27v1PAG-AQ772BVq-TZts
oauth2:
authorizationserver:
issuer: http://localhost:8214
client:
reporting-service:
web-app:
spa: JdbcClient is enough for the lookup, and Basics 34 covers the rest of the idea, BCrypt included.
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:
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" 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) 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 thetextcolumns. - Nothing deletes them. The public methods of
JdbcOAuth2AuthorizationServicearesave,remove,findByIdandfindByToken, 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.
Do consent and authorizations survive a restart?
With the JDBC services, yes. Alice's access token from before a restart, introspected after it:
{
"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.

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:
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:
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_ADMINplus theFACTOR_PASSWORDof Spring Security 7, which theROLE_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:
{
"kid": "key-2026-12",
"alg": "RS256"
}{
"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:
== 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"}
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:
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:
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'["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:
.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:
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()); {"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:
curl -s -u resource-server:XYs4XlGxsLbXcZ99uD9CdCYa --data-urlencode "token=$OPAQUE" http://localhost:8214/oauth2/introspect | jq .{
"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").
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=XYs4XlGxsLbXcZ99uD9CdCYaspring:
security:
oauth2:
resourceserver:
opaquetoken:
introspection-uri: http://localhost:8214/oauth2/introspect
client-id: resource-server
client-secret: XYs4XlGxsLbXcZ99uD9CdCYaimport org.springframework.context.annotation.Profile;
@Configuration
@Profile("!opaque")
public class SecurityConfig {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 server | Min | Median | p90 | Max | Calls to the authorization server | ab -c 8 | Load (1 min) |
|---|---|---|---|---|---|---|---|
| JWT, JWKS cached | 0.43 ms | 0.68 ms | 0.99 ms | 3.98 ms | 1 JWKS refresh in 600 requests, 0 introspections | 22,803 req/s | 6.06 → 5.97 |
| Opaque, introspection | 61.88 ms | 64.55 ms | 66.72 ms | 110.06 ms | 600 introspections for 600 requests | 99 req/s | 6.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.1 | Keycloak 26.7.4 start-dev | |
|---|---|---|
Start to first 200 on discovery | 1.55 s, 1.58 s, 1.76 s | 11.7 s and 12.7 s for a new container, 3.9 s for a restart |
| Its own startup log line | Started AuthserverApplication in 1.246, 1.227, 1.412 seconds | started in 6.771s and 7.344s, 3.499s on restart |
| Memory after startup | RSS 205 to 210 MiB | JVM 581 and 592 MiB; container 569 and 580 MiB |
| After 1000 token requests | RSS 216 MiB | JVM 636 MiB; container 624 MiB |
Token requests per second, -c 8 | 102 (each pays a 57.5 ms BCrypt check) | 912 |
| Heap setting | -Xmx512m | the image's -XX:MaxRAMPercentage=70, about 1075 MiB of the 1536 MiB limit |
| Load average (1 min) | 3.66 to 4.93 | 3.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 Server | Keycloak | |
|---|---|---|
| Shape | a library inside your Spring Boot application | a separate server you run next to your applications |
| Admin console | none: clients came from properties, then from code | web admin console and admin REST API |
| Users | whatever your UserDetailsService or AuthenticationProvider reads | its own user store, registration, password reset, account console |
| User federation | yours to write | LDAP and Active Directory, Kerberos, a User Storage SPI |
| Social login and brokering | wire oauth2Login into the login chain yourself | Google, GitHub and other identity providers built in (article 13) |
| MFA | building blocks in Spring Security 7 (@EnableMultiFactorAuthentication, the FACTOR_ authorities seen above); screens and enrolment are yours | OTP and WebAuthn built in |
| Protocols | OAuth 2.1 and OpenID Connect | OAuth 2.0, OpenID Connect and SAML 2.0 |
| Custom claims | a customizer bean, unit-testable like any Spring bean | protocol mappers in the console, or a Java SPI provider |
| Login and consent pages | defaults for development (the consent page loads Bootstrap from a CDN); you write the real ones | themeable pages with FreeMarker |
| Persistence | three tables from the jar, adapted by hand for PostgreSQL; expired rows are never deleted | its own schema, migrated automatically when a new version starts |
| Upgrades | with Spring Boot and Spring Security, in your build | a new image; the database is migrated on start |
| Clustering | stateless apart from the login HttpSession: a shared database plus Spring Session or sticky sessions | several nodes sharing the database, with embedded Infinispan caches |
| Footprint here | 1.6 s, 216 MiB | 11.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-uriand 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.