1
0
Files
spring-auth-demo/docs/09-manual-filter-vs-resource-server.md
Ankur Mhatre 4dc45d5e00 Add OAuth2 resource server project: JWT validation, JWKS and key rotation
Companion code for the follow-up article. The repository now holds two Maven
projects sharing one docs/ tree:

  jwt-authentication/       the hand-written filter application (unchanged, moved)
  oauth2-resource-server/   a resource server, a Keycloak compose, and a stub
                            issuer whose JWK Set can be mutated on command

The stub exists because Keycloak will not rotate a signing key at a chosen
second, report how many times its JWKS endpoint was fetched, or drop a key from
the published set on request - and the caching and rotation measurements need
all three. The Keycloak run confirms the same code path against a real issuer.

Findings captured under docs/output/, all from real runs:

  * The default validator stack does not check aud. A token minted for another
    service in the same realm is accepted.
  * Spring Security builds its JWKSource with refreshAheadCache(false) and
    rateLimited(false), overriding two of Nimbus's protective defaults, and
    enables Nimbus caching only when NO Spring cache was supplied - so
    supplying one removes the five-minute expiry.
  * A key retired from the JWK Set stops being accepted at t+300s with the
    default cache, and never with a Spring cache that has no TTL.
  * 25 tokens carrying an unknown kid produce 25 JWKS fetches at the issuer,
    through permitAll() endpoints included.
  * A hyphenated client id in an authorities-claim-expression parses as
    subtraction; the SpelEvaluationException is swallowed and logged at TRACE.
  * A clientScopes key in a Keycloak realm import replaces the built-in scopes
    rather than adding to them.

New docs chapters 12-18. README covers both projects. Existing docs and scripts
updated for the new paths; no docs/output/ file from the first article moved, so
links in the published article still resolve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013f7f2XZXrQ6gW3RtZE187t
2026-08-23 11:00:56 +00:00

5.2 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

ManualSecurityConfig:

.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-inResourceServerSecurityConfig:

.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)

Files: loose · strict.

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.