Skip to main content

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.

Every article about Spring Authorization Server that ranked well when I started this one begins with the same line of code:
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
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.
If you want…start at
the shortest provider that actually works on 4.1The provider, in two filter chains
to know why your existing client suddenly gets invalid_requestThe default that flipped, twice
a consent page that does not redirect-loopYour own consent page
tokens a downstream service can authorise onPutting something useful in the token
to decide whether to run one at allShould you be doing this
Code: ankurm.com/git.app/asmhatre/spring-auth-demo, in authorization-server/. One command regenerates every transcript quoted below.

There is nothing to pin

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.
$ grep -oP '<spring-security\.version>[^<]+' spring-boot-dependencies-4.1.1.pom
<spring-security.version>7.1.1

$ curl -s .../spring-security-bom/7.1.1/spring-security-bom-7.1.1.pom | grep -A1 authorization-server
        <artifactId>spring-security-oauth2-authorization-server</artifactId>
        <version>7.1.1</version>
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:
… 1.5.6  1.5.7  1.5.8  2.0.0-M1  2.0.0-M2  7.0.0-M3  7.0.0-RC1 … 7.0.0  7.0.1 … 7.1.1  7.2.0-M1
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.
Where the configuration classes live spring-security-oauth2-authorization-server 1.5.8 org.springframework.security.oauth2.server .authorization.config.annotation.web .configuration.OAuth2AuthorizationServerConfiguration .configurers.OAuth2AuthorizationServerConfigurer public static void applyDefaultSecurity(HttpSecurity) both classes, one jar, one package root spring-security-config 7.1.1 org.springframework.security.config.annotation.web .configuration.OAuth2AuthorizationServerConfiguration .configurers.oauth2.server.authorization .OAuth2AuthorizationServerConfigurer applyDefaultSecurity(..) — removed different jar, and two different package roots The two classes did not move together. One went to `.configuration`, the other to a package five segments deeper. An IDE’s “organise imports” finds the first and often not the second. Replacement: build the configurer yourself and register it with http.with(new OAuth2AuthorizationServerConfigurer(), server -> …)
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
Nine lines of copied configuration, four errors. Full transcript: as-legacy-compile-failure.txt.

The provider, in two filter chains

@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.
Two chains, and why the order is not cosmetic 1. @Order(HIGHEST_PRECEDENCE) securityMatcher(authorizationServer.getEndpointsMatcher()) /oauth2/authorize /oauth2/token /oauth2/jwks /oauth2/introspect /oauth2/revoke /userinfo /.well-known/openid-configuration Declines everything else. 2. everything not matched above formLogin(withDefaults()) /login the credential form /oauth2/consent your own page, session-authenticated /diag/** diagnostics A browser session, not a bearer token. Reverse them and this happens POST /oauth2/token → 302 Location: /login The catch-all chain matches first, so the token endpoint is never reached. That redirect is the fingerprint. The protocol chain must come first because it is the narrower matcher; Spring Security consults chains in order and stops at the first match. GET /diag/chains on the running server prints the live ordering rather than the intended one.
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.
RegisteredClient.withId(UUID.randomUUID().toString())
        .clientId("demo-web")
        .clientSecret(encoder.encode("web-secret"))
        .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
        .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
        .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
        .redirectUri("http://127.0.0.1:8080/login/oauth2/code/demo-web")
        .scope(OidcScopes.OPENID).scope("orders.read").scope("orders.write")
        .clientSettings(ClientSettings.builder()
                .requireAuthorizationConsent(true).build())
        .tokenSettings(TokenSettings.builder()
                .accessTokenTimeToLive(Duration.ofMinutes(5))
                .reuseRefreshTokens(false)
                .build())
        .build();
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.
=== Spring Authorization Server 1.5.8 (last release of the standalone project) ===
requireProofKey            = false
requireAuthorizationConsent= false
accessTokenTimeToLive      = PT5M
accessTokenFormat          = self-contained
refreshTokenTimeToLive     = PT1H
reuseRefreshTokens         = true

=== Spring Authorization Server 7.1.1 (inside Spring Security, Boot 4.1.1 BOM) ===
requireProofKey            = true
requireAuthorizationConsent= false
accessTokenTimeToLive      = PT5M
accessTokenFormat          = self-contained
refreshTokenTimeToLive     = PT1H
reuseRefreshTokens         = true

=== spring-security-oauth2-client 6.5.1 ===
ClientRegistration.ClientSettings.requireProofKey = false

=== spring-security-oauth2-client 7.1.1 (Boot 4.1.1 BOM) ===
ClientRegistration.ClientSettings.requireProofKey = true
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.
What still talks to a 7.1 authorization server client in front of it sends code_challenge? result Spring Security 7.1 client yes — default is now true works Spring Security 6.x, confidential no rejected before login hand-rolled / non-Spring client only if you added it rejected before login a Postman collection from last year depends what was saved the one nobody tests before release The rejection is a redirect back to the client, so what a user sees is the CLIENT’s error page, and nothing in the client’s logs names the provider as the cause. The reason exists only in a query string the client then discards: ?error=invalid_request&error_description=OAuth%202.0%20Parameter%3A%20code_challenge
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:
302  http://127.0.0.1:8080/orders
302  http://127.0.0.1:8080/oauth2/authorization/demo-web
302  http://localhost:9000/oauth2/authorize?response_type=code&client_id=demo-web&…&nonce=…
     >>> NO code_challenge
302  http://127.0.0.1:8080/login/oauth2/code/demo-web
       ?error=invalid_request
       &error_description=OAuth%202.0%20Parameter%3A%20code_challenge
       &error_uri=…rfc7636%23section-4.4.1
200  http://127.0.0.1:8080/login?error
The user never reaches a login page. They land on the client’s generic error page, four hops from the actual cause.

Why the client sends it now, and why you cannot turn it off where you expect

The rule lives in DefaultOAuth2AuthorizationRequestResolver.getBuilder(...), and it is clearer in bytecode than in prose:
57: getstatic     ClientAuthenticationMethod.NONE
64: invokevirtual ClientAuthenticationMethod.equals
67: ifne          80
71: invokevirtual ClientRegistration$ClientSettings.isRequireProofKey
77: ifeq          89
80: getstatic     DEFAULT_PKCE_APPLIER
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:
private final CodeVerifierAuthenticator codeVerifierAuthenticator;
…
// String invalid_client
// String https://datatracker.ietf.org/doc/html/rfc6749#section-3.2.1
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. Wiring one is a single line:
.authorizationEndpoint(endpoint -> endpoint.consentPage("/oauth2/consent"))
What is not documented is the contract the form has to satisfy, and every way of breaking it produces a redirect loop rather than an error message.
the form must…if it does not
POST to /oauth2/authorize, not to the consent path404, or a brand-new authorization request
echo state as the consent page received itredirect loop
echo client_idinvalid_request
send one scope parameter per approved scopeconsent appears to succeed; the token comes back short
include the CSRF token403 — this is the browser chain, not the protocol chain
The state row is the one that costs an afternoon, because the value is not the one you sent. From a real run:
GET /oauth2/authorize?…&state=xyz123
-> 302 /oauth2/consent?scope=openid%20orders.read&client_id=demo-spa
        &state=RXHrz8avEvUmNxYMLZoT0CyJS2E0t99pJtMJ5fyJBVM%3D

… consent approved …

-> 302 http://127.0.0.1:8080/authorized?code=B6iUSZ…&state=xyz123
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:
  default                              with the customiser
  {                                    {
    "aud": "demo-spa",         <-->      "aud": "orders-api",
                                         "roles": ["ADMIN", "USER"],
                                         "tenant": "acme",
    "exp": …,                            "exp": …,
    "iss": "http://localhost:9000",      "iss": "http://localhost:9000",
    "scope": ["openid","orders.read"],   "scope": ["openid","orders.read"],
    "sub": "alice"                       "sub": "alice"
  }                                    }
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:
access token   "aud": "orders-api"
id_token       "aud": "demo-spa",  "azp": "demo-spa",  "sid": "1ZK2c__DhcDY…"
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.

The resource server, when the issuer is yours

One property gets you most of the way:
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:9000
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:
["SCOPE_openid","ROLE_USER","SCOPE_orders.read","ROLE_ADMIN",
 "FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=2026-08-24T01:52:21.799Z]"]
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:
Acceptas documentedwith setIgnoredMediaTypes(ALL)
*/*302 → /login401
application/json401401
text/html302 → /login302 → /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):
302  http://127.0.0.1:8080/orders
302  http://127.0.0.1:8080/oauth2/authorization/demo-web
302  http://localhost:9000/oauth2/authorize?…&code_challenge=…&code_challenge_method=S256
200  http://localhost:9000/login
302  POST http://localhost:9000/login
302  http://localhost:9000/oauth2/authorize?…&continue
200  http://localhost:9000/oauth2/consent?…
302  POST http://localhost:9000/oauth2/authorize
302  http://127.0.0.1:8080/login/oauth2/code/demo-web?code=g51U-dZi…&state=…
200  http://127.0.0.1:8080/orders
Ten steps for one login, ending with the resource server’s answer rendered by the client:
{orders=[{id=1, total=42.00}], subject=alice, scopes=[orders.write, openid, profile,
 orders.read], roles=[ADMIN, USER], tenant=acme, audience=[orders-api]}
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 repositoryspring-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. The rest of this series Primary sources

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.