cors-csrf — CORS, CSRF and SameSite, reproduced state by state
Companion project for CORS, CSRF and SameSite in Spring Boot 4: The Three Settings Everyone Gets Wrong on ankurm.com.
Every broken state the article describes is a Spring profile on this one application, and every
transcript under docs/output/ was produced by running it. No browser is
required: a CORS preflight is an OPTIONS carrying two headers, and curl sends those.
Versions
| Version | Notes | |
|---|---|---|
| JDK | 25 (Temurin 25.0.4.1+1) | current LTS |
| Spring Boot | 4.1.1 | inherited as parent, so everything below is Boot-managed |
| Spring Framework | 7.0.9 | CorsFilter, DefaultCorsProcessor, CorsConfiguration |
| Spring Security | 7.1.1 | CorsConfigurer, CsrfConfigurer.spa() |
| Tomcat | 11.0.24 | |
| JUnit Jupiter / AssertJ | Boot-managed | 23 assertions |
Versions were read from repo1.maven.org/.../maven-metadata.xml, not from release
announcements. Note that maven-metadata.xml's own <release> element pointed at
4.2.0-M1 while this was written; a milestone is not a release.
Quickstart
./scripts/run.sh securitysource # the configuration that works
./scripts/preflight.sh # one preflight, headers printed
./scripts/run.sh mvconly # the same app with CORS on the MVC layer only
./scripts/preflight.sh # → 401
./scripts/run-all.sh # every scenario, regenerating docs/output/
mvn test # just the 23 assertions
./scripts/stop.sh
The user is alice / password.
Profiles
| Profile | Configuration | Shows |
|---|---|---|
securitysource (default) |
SecuritySourceConfig |
A bean named corsConfigurationSource. This one works |
mvconly |
MvcOnlySecurityConfig |
addCorsMappings and nothing else — preflight answered 401 |
mvcbridge |
MvcBridgeSecurityConfig |
The same MVC config plus .cors(withDefaults()) — and MVC's max-age default |
misnamed |
MisnamedSourceConfig |
Right type, wrong bean name — preflight answered 200 with no CORS headers |
twosources |
TwoSourcesConfig |
Two sources. The docs say CORS is not configured; it is, and the name decides |
wildcard |
WildcardCredentialsConfig |
allowedOrigins("*") with credentials — fails on the request, surfaces as 401 |
csrfnaive |
CsrfNaiveConfig |
The pre-6.0 SPA recipe: no cookie on the GET, 403 on the POST |
csrfspa |
CsrfSpaConfig |
csrf.spa(), and why the cookie now arrives on the bootstrap GET |
spaorder |
CsrfSpaOrderConfig |
csrfTokenRepository(..) before spa() — silently discarded |
crosssite |
CsrfSpaCrossSiteConfig |
SameSite=None; Secure on the CSRF cookie |
errorpermit |
ErrorDispatchConfig |
Add-on. Combine with any other profile to see the status code the /error dispatch was hiding |
Add-on profiles combine: ./scripts/run.sh csrfnaive,errorpermit.
Three environment settings change behaviour rather than configuration:
| Setting | Effect |
|---|---|
SESSION_SAME_SITE / SESSION_SECURE |
The session cookie's attributes, written straight through by Boot |
JVM_ARGS=-DOMIT_SECURE=true |
Under crosssite, emit SameSite=None without Secure |
CORS_LOG_LEVEL / CSRF_LOG_LEVEL |
DEBUG turns on the two log categories that answer almost every question here |
Endpoints
| Endpoint | Purpose |
|---|---|
GET/POST /api/data |
The API the imaginary SPA calls |
GET /api/whoami |
Who the request authenticated as |
GET /api/boom |
Throws, so you can watch the error dispatch |
GET /diag/chain |
The filters FilterChainProxy actually holds |
GET /diag/cors-sources |
Every CorsConfigurationSource bean, by name |
GET /diag/cookie-spec?h=…&secure=… |
Real Set-Cookie headers run through the RFC 6265bis rules |
The /diag/** endpoints are permitted without authentication so the scripts can read them.
Delete them before shipping.
Documentation
| Chapter | |
|---|---|
| 01 | Two layers, one word — why MVC CORS does not fix a security-layer rejection |
| 02 | Who resolves the CorsConfigurationSource — by type, then by name |
| 03 | The three identical 403s, and reading a status code as a diagnosis |
| 04 | PreFlightRequestHandler, and the wildcard that is not allowed |
| 05 | The /error dispatch, or why your 403 arrives as a 401 |
| 06 | CSRF for SPAs, and what spa() actually assigns |
| 07 | SameSite, Secure, and the cookie that is never stored |
| 08 | Debugging recipes |
Captured output
| File | |
|---|---|
| 01-mvc-only.txt | An 11-filter chain with no CorsFilter, and a 401 preflight |
| 02-mvc-bridge.txt | The same app plus one line — 200, and Access-Control-Max-Age: 1800 |
| 03-security-source.txt | A corsConfigurationSource bean — 200, and no max-age |
| 04-three-identical-403s.txt | Three rejections, one response, three DEBUG lines |
| 05-misnamed-bean.txt | 200 with no CORS headers, and Skip: no CORS configuration has been provided |
| 06-two-sources.txt | Two sources, CORS configured anyway, the named bean winning |
| 07-wildcard-credentials.txt | The IllegalArgumentException, arriving as a 401 |
| 08-csrf-naive.txt | No cookie on the GET, then two rejected POSTs |
| 09-error-dispatch.txt | The same failure with /error permitted — the 403 reappears |
| 10-csrf-spa.txt | csrf.spa(): cookie on the GET, raw value accepted in the header |
| 11-spa-ordering.txt | A custom repository silently discarded by spa() |
| 12-samesite.txt | Four sets of real Set-Cookie headers, run through the RFC rules |
| 13-tests.txt | mvn test |
Related modules
filter-chain/— the order numbers this module keeps citing, and the/errordispatch in fullcontext-propagation/— whether theSecurityContextsurvives leaving the request threadmethod-security/— reading that context back