Spring Authorization Server: Running Your Own OAuth2 / OIDC Provider (Spring Boot 4.1)
Building a real OAuth2 / OIDC provider on Spring Boot 4.1 with Spring Authorization Server 7.1: client registration, PKCE, a custom consent page and token customisation, across an authorization server, a relying party and a resource server. The 7.0 move into Spring Security deleted applyDefaultSecurity and relocated both configuration classes, and it flipped the requireProofKey default from false to true on the server and the client alike — verified by compiling against both versions of the jars. Every transcript comes from a run you can reproduce.
That method no longer exists. Neither does the package the class used to live in. If you paste a 2024 tutorial into a Spring Boot 4.1 project, you get four compiler errors before you reach anything interesting — and the two most consequential changes in this release are not compiler errors at all. They are defaults that quietly flipped, on both the server and the client, and they decide whether your existing integrations still work.
This article builds a real provider: an authorization server, a relying party that logs into it, and a resource server that trusts what it mints. Three modules, three ports, one browser flow you can watch hop by hop. Everything here was compiled and run; every transcript is committed in the companion repository rather than retyped.
Versions. JDK Temurin 25.0.4.1+1 (current LTS) · Spring Boot 4.1.1 · Spring Framework 7.0.9 · Spring Security and Spring Authorization Server 7.1.1 · Maven 3.9.11.
Spring Authorization Server 7.0.0 went GA as part of Spring Security 7.0 following the September 2025 announcement that the project was moving into Spring Security. Every version number above was read from maven-metadata.xml and from the spring-boot-dependencies:4.1.1 POM, not from a release blog.
The brief for this article was “pin the Spring Authorization Server version from the Boot 4.1 BOM”. There is no such property. spring-boot-dependencies:4.1.1 has no <spring-authorization-server.version>, because Spring Authorization Server is no longer a separate project.
The Maven coordinates are unchanged — org.springframework.security:spring-security-oauth2-authorization-server — and the version now tracks Spring Security. Boot 4.1.1 gives you 7.1.1. That is the pin, and it arrives through spring-security-bom rather than through anything you write.
The published version list on Maven Central is worth reading on its own:
There is no Spring Authorization Server 2.x.2.0.0-M1 and 2.0.0-M2 exist as milestones and were abandoned; the line was renumbered to 7.0.0 to align with Spring Security. Anything telling you to “upgrade to SAS 2” is describing a version that never went GA. This is the second time in a year I have hit a Spring-adjacent library whose “2.0” only ever existed as a milestone, and both times the aggregators reported it as released.
There is also a starter rename you will trip over. Boot 4.1 publishes two starters that resolve to exactly the same four dependencies, and one of them tells you in its own POM that it is finished:
<artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
<description>Starter for using Spring Authorization Server features (deprecated in favor
of spring-boot-starter-security-oauth2-authorization-server)</description>
Use spring-boot-starter-security-oauth2-authorization-server. The client and resource-server starters got the same security- prefix, and there is a new -test variant. Note also that the starter pulls in spring-boot-starter-webmvc — Boot 4’s rename of spring-boot-starter-web — so you do not declare a web starter at all.
The four compiler errors
Two classes left the Spring Authorization Server jar for spring-security-config, and the static method everyone calls was deleted along the way.
The repository keeps the pre-7.0 configuration in src-broken/, outside the build, and a script compiles it against the real 7.1.1 classpath so the error text in the article is the error text you will see:
$ javac -cp <spring-boot-4.1.1 classpath> LegacySasConfig.java
error: package org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration does not exist
error: package org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers does not exist
error: cannot find symbol
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
^
symbol: variable OAuth2AuthorizationServerConfiguration
error: cannot find symbol
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
^
symbol: class OAuth2AuthorizationServerConfigurer
4 errors
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain authorizationServerChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfigurer authorizationServer =
new OAuth2AuthorizationServerConfigurer();
http
.securityMatcher(authorizationServer.getEndpointsMatcher())
.with(authorizationServer, server -> server
.oidc(Customizer.withDefaults())
.authorizationEndpoint(endpoint -> endpoint.consentPage("/oauth2/consent")))
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.exceptionHandling(ex -> ex.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"), htmlOnly()))
.oauth2ResourceServer(rs -> rs.jwt(Customizer.withDefaults()));
return http.build();
}
That chain owns the protocol endpoints and nothing else, because getEndpointsMatcher() narrows it to them. A second chain picks up everything that is left — the login form, the consent page, static assets — using a browser session rather than a bearer token.
Three details in that configuration earn their place.
OIDC is not on by default..oidc(Customizer.withDefaults()) is one line, and omitting it costs you /userinfo, the id_token, and /.well-known/openid-configuration. You still get an OAuth2 metadata document at /.well-known/oauth-authorization-server — they are two different documents, and as-discovery.txt prints both side by side.
The signing key. This demo generates an RSA keypair per boot, which means a restart invalidates every token it ever issued. That is deliberate: it makes the behaviour obvious now rather than during your first production restart.
Two beans you have to declare. This one cost a startup failure. A custom consent page has to read OAuth2AuthorizationConsentService, and that is not an injectable bean — the configurer creates one for its own use. Constructor-inject it and the context refuses to start:
org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean
with name 'consentController' … Unsatisfied dependency expressed through constructor
parameter 1: No qualifying bean of type 'org.springframework.security.oauth2.server
.authorization.OAuth2AuthorizationConsentService' available: expected at least 1 bean
which qualifies as autowire candidate.
Declare OAuth2AuthorizationService and OAuth2AuthorizationConsentService yourself. Not only to make the injection work — declaring them forces the storage decision into the open. The in-memory implementations are per-instance, which means two replicas of your authorization server cannot complete each other’s code exchanges, and a user who consents on one will be asked again by the other. There are JDBC implementations, and they bring schema migrations with them.
A client is a policy, not a credential
RegisteredClient is where most of the surprises live. It states which grants a caller may use, which redirect URIs are acceptable, which scopes it may request, whether the user must consent, whether PKCE is mandatory, and how long the tokens live. Nearly every “works in Postman, not in the browser” report is one of those fields.
encoder.encode(...) is not decoration. The secret is stored hashed, and registering the bare string then sending it produces invalid_client with no further explanation, because the server bcrypt-compares what you sent against what it thinks is a hash. The redirect URI is matched exactly — scheme, host, port and path, no wildcards — and a mismatch is rejected before login and rendered by the authorization server rather than sent to the client, because redirecting to an unvalidated URI is the vulnerability the check exists for.
reuseRefreshTokens(false) is rotation. The transcript shows what that buys:
old refresh token: 1BYxixPcmy4PLVmNvkTIo-00...
new refresh token: P-7KeaSx9alEBp5CFpDBt-c0...
DIFFERENT - reuseRefreshTokens(false), the old one is now dead
Replaying the old one:
{"error":"invalid_grant"}
One asymmetry worth knowing before you design around it: the public client in this project is registered with REFRESH_TOKEN and never receives one. The gate is in OAuth2RefreshTokenGenerator, which returns null when the authenticated client’s method is ClientAuthenticationMethod.NONE — not in the code-grant provider, where I first went looking. The confidential client, identically registered, gets one. Compare as-authcode-pkce.txt with as-authcode-web.txt.
The default that flipped, twice
Here is the part that will actually break something you already run.
I did not read this in release notes. I compiled one nine-line program against two versions of the jar and printed what the builders hand you. Source is tools/SettingsDefaults.java; output is as-settings-defaults.txt.
PKCE is now mandatory for every client you did not think about. The machine-to-machine client in this project never touches ClientSettings, and reading it back from the running server reports "requireProofKey": true. The same default flipped on the client side in the same release, which is the only reason Spring-to-Spring integrations survived it.
Both sides moving together is what makes this a quiet change rather than a loud one. It also tells you exactly which combinations break.
The bottom half of that diagram is a real run, not a sketch. as-client-flow-nopkce.txt drives the actual Spring OAuth2 client, downgraded to the 6.x default, through the actual provider:
PKCE is applied when the registration is a public client or when its ClientSettings.requireProofKey is set — and the builder for that now initialises the flag to true. The consequence for configuration is unintuitive: setting an authorization-request customizer can turn PKCE on, but cannot turn it off, because the default applier runs inside getBuilder independently of your customizer. To disable it you must rebuild the ClientRegistration with requireProofKey(false).
A @Bean that takes ClientRegistrationRepository and returns one is a dependency cycle, and Boot refuses to start with “Relying upon circular references is discouraged and they are prohibited by default.” Post-process the repository Boot already built with a static BeanPostProcessor instead. That is what the demo’s nopkce profile does.
“requireProofKey(false)” does not mean what it looks like
I expected turning the flag off on a public client to reproduce the classic stolen-code attack. It does not, and the reason is more interesting than the attack.
Two experiments. With the flag off but a challenge still in the authorization request, the token endpoint still demands the verifier — sending a challenge and then omitting the verifier is never accepted (as-authcode-nopkce.txt). With the flag off and no challenge at all, the authorization endpoint happily issues a code — and the exchange then fails with a bare 401 (as-authcode-nochallenge.txt).
Disassembling PublicClientAuthenticationProvider explains it:
For a client registered with ClientAuthenticationMethod.NONE, the code verifier is the client authentication — there is nothing else. requireProofKey(false) relaxes the authorization endpoint only; at the token endpoint it does not make PKCE optional, it makes the client unable to authenticate at all. If you were planning to disable PKCE for a legacy SPA, that is not a configuration you can reach.
The client sent state=xyz123. The consent page is handed RXHrz8av…, which is the authorization server’s own correlation handle for the pending request. Echo the client’s value back instead — the obvious thing to do — and the endpoint cannot find the pending authorization, so it starts a new one, which redirects to the consent page again. The loop looks like a session problem and is not. The client’s state reappears untouched at the very end, which is what it is for.
Two smaller things. openid is requested implicitly and the server never asks for consent on it, so rendering it as a checkbox is misleading — unticking it does nothing. And denying is not a separate endpoint: you POST with no scope parameters at all, and the endpoint redirects to the client with error=access_denied.
Consent is also remembered, keyed by client and principal, so a second authorization for already-approved scopes skips the page entirely. That bit me while writing the demo scripts — the second run of a scenario silently took the no-consent path and proved nothing, which is why the harness restarts the authorization server between runs. In production, remember that the in-memory store loses all of it on restart and is per-instance: two replicas will ask the same user twice.
Putting something useful in the token
One bean of type OAuth2TokenCustomizer<JwtEncodingContext> is picked up automatically by the JWT generator. There is no annotation and no registration step — and no diagnostic if you get the generic type wrong. Declare it as OAuth2TokenCustomizer<OAuth2TokenClaimsContext>, which is the type for opaque tokens, and it is silently ignored. Your claims are simply absent.
The interesting question is what the default token already contains. Running the same flow with and without the customiser answers it:
aud defaults to the client id, not the API. There is no per-client audience setting on RegisteredClient, so if your resource servers validate audience — and they should — the token customiser is the only place to set it. A resource server checking aud == "orders-api" will reject every default-issued token, and the failure looks like a signing problem.
Beyond scope, nothing about the user is in there. Roles, tenant, entitlements: you put them in the token or you make a network call per request. Two cautions when you do. Guard on the grant type — client_credentials has no user, and getPrincipal() then returns the client’s own authentication, so a machine token quietly inherits whatever that carried. And keep the id_token separate:
Sending the id_token to a resource server is the classic mix-up, and it is nasty because the token is completely valid — same issuer, same signing key. If you validate audience you get a clean rejection:
HTTP/1.1 401
WWW-Authenticate: Bearer error="invalid_token",
error_description="An error occurred while attempting to decode the Jwt:
the required audience orders-api is missing",
resource_metadata="http://localhost:8090/.well-known/oauth-protected-resource"
If you do not, it passes, and a token the browser was allowed to read becomes a token your API accepts. Put authorisation data in the access token; put profile data in the id_token; treat the id_token as something the client renders a username from and nothing more.
At startup Spring fetches /.well-known/openid-configuration, reads jwks_uri from it, and builds a decoder. You get signature verification, exp/nbf, and an iss check. You do not get an audience check — which, given the paragraph above, is the gap that matters most when you own both ends. I covered the resource-server side at length in the JWKS and key rotation article; what changes here is the coupling.
java.lang.IllegalArgumentException: Unable to resolve the Configuration with the
provided Issuer of "http://localhost:9000"
org.springframework.web.client.ResourceAccessException: I/O error on GET request for
"http://localhost:9000/.well-known/openid-configuration": Connection refused
If the provider is down, the resource server does not start. That is the right default — fail fast rather than serve unauthenticated traffic — but it means a provider outage during a rolling deploy takes every API with it, and now that you run the provider yourself, that outage is yours to cause. If that is unacceptable, configure jwk-set-uri directly and validate iss yourself: you lose discovery, you gain independence at startup.
Two things in the response will be new if you last looked at Spring Security 6. The authorities list now contains a factor:
FactorGrantedAuthority records how the principal authenticated, for multi-factor authorisation rules. It appears in every authority list now, so any test asserting on the exact contents of getAuthorities() fails on upgrade. And WWW-Authenticate carries a resource_metadata parameter — RFC 9728 protected resource metadata, published by default, on an endpoint you did not add.
Why your token endpoint returns HTML
This one is small, silent, and invisible until you add your first SPA.
The protocol chain needs one entry point to do two things: send a browser hitting /oauth2/authorize to the login page, and send a machine hitting /oauth2/token a protocol error. The documented way to express that is a media-type matcher:
.exceptionHandling(ex -> ex.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)))
On its own it does not work, because MediaTypeRequestMatcher treats */* as matching text/html — and */* is what curl, most HTTP clients, and anything that does not set Accept send. Same request, three Accept headers, both configurations, from as-entrypoint-accept.txt:
Accept
as documented
with setIgnoredMediaTypes(ALL)
*/*
302 → /login
401
application/json
401
401
text/html
302 → /login
302 → /login
The fix is one line:
MediaTypeRequestMatcher matcher = new MediaTypeRequestMatcher(MediaType.TEXT_HTML);
matcher.setIgnoredMediaTypes(Set.of(MediaType.ALL));
The browser case is unchanged either way; only the */* case moves, and that is the case every API client falls into. Note also why only public clients hit it: a confidential client with a wrong secret never reaches the entry point, because OAuth2ClientAuthenticationFilter writes the error itself. It is the public client — whose only authentication is the code verifier — that falls through with nothing to authenticate. So the bug sits dormant until the day someone registers a SPA.
The whole thing, hop by hop
None of the above is observable without all three applications running. Driving the real Spring OAuth2 client with curl so every redirect is visible (as-client-flow.txt):
Run the client on 127.0.0.1 and the provider on localhost. They are the same machine and different origins to a browser cookie jar. Put both on localhost and the two JSESSIONID cookies collide — one application’s session clobbers the other’s — and you get a login loop that reads like a Spring Security bug and is a cookie-scope problem.
Everything else that bit
One line each, with the chapter that reproduces it:
Reference tokens are one setting away.TokenSettings.accessTokenFormat(REFERENCE) gives you an opaque string and instant revocation, at the cost of a call to /oauth2/introspect on every API request. Note the customiser does not run for them — opaque tokens go through OAuth2TokenClaimsContext. Chapter 05.
Mapping a custom roles claim can delete your scopes. A JwtGrantedAuthoritiesConverter replacement that only handles roles silently drops every SCOPE_* authority, turning a valid token into a 403. Chapter 06, and its properties-driven twin in chapter 14.
The issuer string is compared byte for byte. A trailing slash on one side fails at validation time with The iss claim is not valid, not at startup, so it presents as a token problem. Chapter 12.
invalid_grant is deliberately uninformative. The token endpoint will not tell you whether the code was wrong, expired, already used, or missing a verifier, because each of those is information an attacker can use. Expect to bisect.
Read the effective configuration back. The interesting settings are spread across three builders and two chains and are printed nowhere at startup. The demo exposes /diag/settings, /diag/clients and /diag/chains, which is how requireProofKey: true on a client nobody configured became visible. Chapter 07 — and delete them before shipping.
Never pkill -f 'spring-boot' in a demo script. The pattern matches the shell running it. Kill by main class and then wait for the port to close; ss -lptn often reports the socket with no PID, so a port-based kill can silently do nothing while the old process keeps serving — which looks exactly like your config change having had no effect.
Should you be doing this
Mostly, no.
This provider is about 700 lines and it is a demo. What it is missing is the actual work: persistent signing keys with a rotating JWK Set rather than a keypair per boot; JDBC-backed authorization, consent and client stores rather than per-instance memory that stops two replicas completing each other’s code exchanges; user registration, password reset, lockout, MFA and audit rather than two hard-coded accounts; rate limiting on the token endpoint and alerting on a spike in invalid_client; and consent records that survive a restart, because those records are the artefact an auditor asks for.
If you want an authorization server because you need “login”, use Keycloak or a hosted IdP. They look heavy because key rotation, storage, user management, MFA and audit are heavy, and you will build all of them eventually. Chapter 17 of the same repository puts Keycloak in front of the same resource server, so the comparison is a diff rather than an argument.
Run your own when you need control a product will not give you — a bespoke consent flow, a token shape a vendor cannot express, an unusual grant — when the identity source is already yours and a second user store is the worse option, or when the deployment forbids a hosted IdP. And run one for a week to learn the protocol; that is a real reason, and it is the reason this repository exists.
There is a middle path I would defend without qualification: run Spring Authorization Server as an internal provider for machine-to-machine traffic only. client_credentials, no users, no consent, no browser flows. That configuration is a fraction of this one, has no session handling at all, and removes most of the list above — while a real IdP handles the humans.
The last thing worth saying is the thing this whole article circles: once you run a provider, its upgrades are your incidents. The requireProofKey default that flipped between 1.5.8 and 7.1.1 is exactly the shape of change that breaks integrations you do not own, on a version bump you thought was routine, with an error message that surfaces on somebody else’s error page. That is the job you are signing up for.
Further reading
The companion repository — spring-auth-demo. Three projects, one docs tree. Regenerate every transcript quoted above with ./authorization-server/scripts/run-all.sh; it needs no Docker and takes a few minutes.
No Comments yet!