Three modules on Spring Boot 4.1.1 with Spring Authorization Server 7.1.1: the provider
itself, a relying party, and an API that trusts its tokens. Client registration, PKCE,
a custom consent page and token customisation, with profiles that make each failure
reproducible.
Every claim is backed by captured output in docs/output/as-*.txt, regenerated by
authorization-server/scripts/run-all.sh. Notable findings, verified against the jars:
- OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(HttpSecurity) was deleted
in 7.0, and both configuration classes moved into spring-security-config
- ClientSettings.requireProofKey flipped from false to true, on the authorization server
(1.5.8 -> 7.1.1) and on the OAuth2 client (6.5.1 -> 7.1.1)
- requireProofKey(false) does not make PKCE optional for a public client; the code
verifier is that client's only authentication at the token endpoint
- MediaTypeRequestMatcher(TEXT_HTML) matches Accept: */*, so the token endpoint answers
API callers with 302 -> /login unless setIgnoredMediaTypes(ALL) is called
Also renames the repository to spring-auth-demo and cross-links the new chapter set from
the existing documentation.
5.5 KiB
09 — Manual filter vs built-in resource server
← testing · next: production checklist →
Both are in this repository, behind profiles, secured identically. Run them side by side:
./jwt-authentication/scripts/run.sh hs256 # hand-written OncePerRequestFilter
./jwt-authentication/scripts/run.sh hs256,resourceserver # oauth2ResourceServer().jwt()
The configuration, side by side
Manual —
SecurityConfig:
.addFilterBefore(new JwtAuthenticationFilter(jwtDecoder, revokedTokens, entryPoint),
UsernamePasswordAuthenticationFilter.class);
plus ~120 lines of filter, plus the entry point and access-denied handler wired by hand.
Built-in —
ResourceServerSecurityConfig:
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())));
One line. BearerTokenAuthenticationFilter is inserted in the right slot,
BearerTokenAuthenticationEntryPoint and BearerTokenAccessDeniedHandler are wired,
the JwtDecoder bean is picked up automatically, and the RFC 6750 headers are correct
on both 401 and 403.
The chains it produces
Manual (transcript step 19):
… HeaderWriterFilter, JwtAuthenticationFilter, RequestCacheAwareFilter, …
Built-in (transcript step 1):
… HeaderWriterFilter, LogoutFilter, OAuth2ProtectedResourceMetadataFilter,
BearerTokenAuthenticationFilter, RequestCacheAwareFilter, …
Same slot. The extra OAuth2ProtectedResourceMetadataFilter is Spring Security 7's
RFC 9728 support — see doc 11.
What moves when you switch
| concern | manual filter | resource server |
|---|---|---|
| resolve the header | DefaultBearerTokenResolver (you call it) |
built in |
| decode + verify | jwtDecoder.decode(token) (you call it) |
built in |
exp/nbf/iss |
JwtValidators on the decoder |
same decoder, same validators |
aud |
your validator | your validator |
token_type |
an if in the filter |
an OAuth2TokenValidator |
denylist / jti |
an if in the filter |
an OAuth2TokenValidator |
| authority mapping | your converter | JwtAuthenticationConverter |
| 401 shape | you wire the entry point | built in |
| 403 shape | you wire the handler | built in |
The two custom checks do not disappear — they move onto the decoder. That is the trap in the next section.
The trap: the built-in path has no opinion about your claims
Run the same request against both resource-server profiles:
# 4. REFRESH token presented as an access token.
HTTP 200 hs256,resourceserver (loose)
HTTP 401 hs256,resourceserver,strict (validator wired)
Switching from a hand-written filter to oauth2ResourceServer() silently drops every
custom check that lived in the filter, because the framework has never heard of your
token_type claim or your denylist. The build still passes. The tests still pass, if
they only test the happy path. Move them explicitly:
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(issuer),
AudienceValidator.forAudience(audience),
new AccessTokenTypeValidator()));
JwtValidatorFactory in this repository composes exactly that, and the extras are
supplied by an ObjectProvider so both profiles get them.
Note also that a denylist check does not belong in an OAuth2TokenValidator on purity
grounds — validators are supposed to be pure functions of the token — but it is where it
has to go if you want it on the built-in path. The alternative is an
AuthenticationSuccessHandler or a small filter after
BearerTokenAuthenticationFilter, which puts you halfway back to the manual approach.
Which to pick
Use the built-in resource server if your tokens are ordinary OAuth 2.0 / OIDC access
tokens, especially from a real authorization server. It is less code, it is maintained,
and it gets RFC compliance right in places you would not think to (the charset in
WWW-Authenticate, RFC 9728 metadata, insufficient_scope on 403).
Write the filter if you need behaviour the framework has no hook for — custom header schemes, a token bound to a device fingerprint, per-request key selection across tenants — or if you are teaching, because the filter is where the flow becomes legible.
What this repository actually recommends: start with the built-in one. If you find
yourself adding OAuth2TokenValidators for things that are not claims, that is the
signal to switch.
A useful third option for a real system: run
Spring Authorization Server as
the issuer and consume its tokens with oauth2ResourceServer(). Then neither half of
this repository is your code.
There is a third option this comparison leaves out: do not mint tokens at all in your
application, and run a real authorization server instead. That is
docs/authorization-server/, and the honest cost/benefit
is in 10 — Should you run one at all.