1
0
Files
jwt-auth-demo/docs/04-csrf-permitall-403.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

6.1 KiB

04 — Why permitAll() still returns 403

← 401 vs 403 · next: HS256 vs RS256 →

This is the single most reported "Spring Security is broken" bug, and it is not a bug.

The symptom

.authorizeHttpRequests(auth -> auth
        .requestMatchers("/api/auth/login").permitAll()
        .anyRequest().authenticated())
POST /api/auth/login
Content-Type: application/json
{"username":"alice","password":"alice-password"}

HTTP 403
WWW-Authenticate: Bearer

Reproduce it: ./jwt-authentication/scripts/run.sh hs256,csrfon then ./jwt-authentication/scripts/csrf-demo.sh. Captured in csrf-vs-permitall.txt.

Note the response body is empty and the header mentions Bearer — which sends people hunting for a token problem. There is no token problem.

The cause, in one number

CsrfFilter is filter 5. AuthorizationFilter — the only filter in the entire chain that has ever heard the word permitAll — is filter 12.

From csrf-trace.txt, a real TRACE log of that exact request:

DEBUG FilterChainProxy : Securing POST /api/auth/login
TRACE FilterChainProxy : Invoking DisableEncodeUrlFilter (1/12)
TRACE FilterChainProxy : Invoking WebAsyncManagerIntegrationFilter (2/12)
TRACE FilterChainProxy : Invoking SecurityContextHolderFilter (3/12)
TRACE FilterChainProxy : Invoking HeaderWriterFilter (4/12)
TRACE FilterChainProxy : Invoking CsrfFilter (5/12)
TRACE CsrfTokenRequestHandler : Did not find a CSRF token in the [X-XSRF-TOKEN] request header
TRACE CsrfTokenRequestHandler : Did not find a CSRF token in the [_csrf] request parameter
DEBUG CsrfFilter : Invalid CSRF token found for http://localhost:8080/api/auth/login

The chain stops at 5 of 12. Filters 6 through 12 never run. permitAll() is a statement about filter 12, and filter 12 is not reached, so permitAll() is not a statement about this request at all.

CsrfFilter throws an AccessDeniedException subtype — MissingCsrfTokenException when the repository had no stored token (the case above, with a fresh client), or InvalidCsrfTokenException when one existed and did not match. Both log the same "Invalid CSRF token found" line, so the message does not distinguish them. Neither reaches ExceptionTranslationFilter, which sits at position 11 — downstream of the filter that threw. CsrfFilter has its own AccessDeniedHandler and answers directly.

   1    2    3    4    5    6    7    8    9   10   11   12
   |----|----|----|----|----X                            .
                            |                            |
                       CsrfFilter                  AuthorizationFilter
                    403, chain stops              knows about permitAll()
                                                    never invoked

Why the header says Bearer

CsrfConfigurer reuses the AccessDeniedHandler you configured under exceptionHandling(). Configure BearerTokenAccessDeniedHandler for your API — correct for real authorization failures — and a CSRF rejection is rendered by it too. You get a 403 with WWW-Authenticate: Bearer and, because there is no OAuth 2.0 error in context, no error= parameter. A bare WWW-Authenticate: Bearer on a 403 is the fingerprint of a CSRF rejection, not a scope problem.

Three fixes, in order of preference

1. Turn CSRF off — correct for a bearer-token API

.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

CSRF exists because browsers attach ambient credentials — cookies, HTTP Basic, client certificates — to cross-origin requests automatically. A browser does not automatically attach an Authorization: Bearer header. Your JavaScript has to read the token out of memory and set it, and same-origin policy stops another site's script from doing that. No ambient credential, nothing to forge.

The condition is strict, and both halves matter:

  • the token is never in a cookie, and
  • no cookie- or session-based authentication remains on any chain.

If you store the JWT in a cookie "for convenience", it is an ambient credential, and you have re-created CSRF exactly. Disabling CSRF at that point is a real vulnerability. See doc 07 — token storage.

2. Exempt the API, keep it for the browser chain

For a mixed application — a server-rendered admin UI plus a token API:

.csrf(csrf -> csrf.ignoringRequestMatchers("/api/**"))

Better still, split into two SecurityFilterChain beans with securityMatcher(), so the API chain has no CsrfFilter at all rather than one that is told to look away.

3. Actually send the token

If the client is a browser SPA that keeps a session:

.csrf(csrf -> csrf.spa())     // Spring Security 7.0+

spa() bundles CookieCsrfTokenRepository, BREACH protection via XorCsrfTokenRequestAttributeHandler, and correct deferred-token loading. The client reads XSRF-TOKEN and echoes it in X-XSRF-TOKEN.

Verified: the method does not exist in 6.4.7 or 6.5.1 and does exist in 7.0.0, so on 6.x you configure those three pieces individually.

Only unsafe methods break. CsrfFilter ignores GET, HEAD, OPTIONS, TRACE. A GET on a permitAll() path works fine, which is why the failure looks intermittent and endpoint-specific. Step B of the demo output shows the same profile answering 200 to a GET.

A 403 with an empty body on POST only is CSRF until proven otherwise.

MockMvc hides it. spring-security-test's .with(csrf()) post-processor makes the test pass while production fails. CsrfBreaksPermitAllTests deliberately has both: one test asserting the 403 without it, one asserting the 200 with it.

CORS is not CSRF. A preflight OPTIONS failing is a CorsFilter problem. Spring Security 7.1 added PreFlightRequestFilter CORS support (gh-18926); if preflight requests are being rejected, look there, not at CSRF.