Software Securityhome
Fix a findingSearch
Fix card · A01:2025 Broken Access Control

Fix CSRF in Java and Spring Boot

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Keep Spring Security's CSRF protection on (it is the default) and never call csrf.disable() for browser-facing routes. Put the _csrf token in every form as a hidden field and send it from scripts in the X-CSRF-TOKEN header. Map state changes with @PostMapping, not a bare @RequestMapping, because the CSRF filter skips GET.

Stack
  • Language Java 21
  • Runtime OpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16
  • Framework Spring Boot 4.1.1 (Spring Security 7.1.1, Spring Framework 7.0.9, Spring MVC)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks6 cards
Download the project ZIP, 26 KB
On this page

Steps

  1. Search the SecurityFilterChain beans for csrf(csrf -> csrf.disable()), csrf(AbstractHttpConfigurer::disable) and broad ignoringRequestMatchers(...), and remove them for routes that browsers reach with the session cookie.
  2. Render the token in every form that posts: Thymeleaf and Spring's form tag library add it automatically; otherwise write <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}">, or take a CsrfToken parameter in the controller as this card does.
  3. For scripts, expose the token in a meta tag and send it in the X-CSRF-TOKEN header; a single-page app that reads it from a cookie should use csrf(csrf -> csrf.spa()) (Spring Security 7.0 and later).
  4. Replace @RequestMapping without a method on state-changing handlers with @PostMapping, @PutMapping, @PatchMapping or @DeleteMapping.
  5. Set server.servlet.session.cookie.same-site=lax and server.servlet.session.cookie.secure=true as defence in depth.
  6. Add regression tests that send the session cookie with the headers a browser sends for a cross-site form, a sibling subdomain, another user's token and a cross-site link. In MockMvc tests, .with(csrf()) supplies a valid token; prove the protection with requests that have no token or use csrf().useInvalidToken().

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerable…/account/impl/VulnerableSecurity.javaJava
// No method attribute, so every method maps here: the form POSTs, and the link in the
// "confirm your new address" email sends GET /account/email?email=...
@RequestMapping("/account/email")
ResponseEntity<String> updateEmail(Principal user, @RequestParam String email) {
    if (!email.matches("[^@\\s]+@[^@\\s]+")) {
        return ResponseEntity.badRequest().body("Enter a valid email address");
    }
    accounts.changeEmail(user.getName(), email);
    return ResponseEntity.status(HttpStatus.SEE_OTHER).location(URI.create("/account")).build();
}

With IMPL=vulnerable, 7 of 12 tests fail (7 exploit tests).

The fix

Fixed…/account/impl/FixedSecurity.javaJava
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
            .authorizeHttpRequests(requests -> requests.anyRequest().authenticated())
            .formLogin(form -> form.defaultSuccessUrl("/account", true))
            // On by default and shown for clarity: POST, PUT, PATCH and DELETE need the token
            // stored in the session, as the _csrf parameter or the X-CSRF-TOKEN header.
            .csrf(Customizer.withDefaults());
    return http.build();
}

With IMPL=fixed, 12 of 12 tests pass.

The fix: change state only on POST

Fixed…/account/impl/FixedSecurity.javaJava
@PostMapping("/account/email") // POST only, so a link cannot reach it
ResponseEntity<String> updateEmail(Principal user, @RequestParam String email) {
    if (!email.matches("[^@\\s]+@[^@\\s]+")) {
        return ResponseEntity.badRequest().body("Enter a valid email address");
    }
    accounts.changeEmail(user.getName(), email);
    return ResponseEntity.status(HttpStatus.SEE_OTHER).location(URI.create("/account")).build();
}

With IMPL=fixed, 12 of 12 tests pass.

A fix that looks safe but is not

Wrong fix…/account/impl/WrongSecurity.javaJava
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http, AccountStore accounts) throws Exception {
    String host = URI.create(accounts.origin()).getHost(); // "portal.example.test"
    http
            .authorizeHttpRequests(requests -> requests.anyRequest().authenticated())
            .formLogin(form -> form.defaultSuccessUrl("/account", true))
            .csrf(csrf -> csrf.disable()) // replaced by the Referer check
            .addFilterBefore(new RefererCheck(host), UsernamePasswordAuthenticationFilter.class);
    return http.build();
}

static final class RefererCheck extends OncePerRequestFilter {

    private static final Set<String> SAFE_METHODS = Set.of("GET", "HEAD", "OPTIONS", "TRACE");
    private final String host;

    RefererCheck(String host) {
        this.host = host;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        String referer = request.getHeader("Referer");
        // Some browsers and privacy tools strip the Referer, so allow requests without one.
        if (SAFE_METHODS.contains(request.getMethod()) || referer == null || referer.contains(host)) {
            chain.doFilter(request, response);
        } else {
            response.sendError(HttpServletResponse.SC_FORBIDDEN, "Cross-site request refused");
        }
    }
}

With IMPL=wrong, 3 of 12 tests fail (3 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Test…/example/account/ExploitTest.javaJava
/**
 * Exploit: each forged request carries Alice's session cookie. Browsers withhold a
 * SameSite=Lax cookie from cross-site POSTs, but not from sibling subdomains or top-level
 * GET navigations, and older browsers ignore SameSite, so the server must refuse anyway.
 */
class ExploitTest extends BrowserTestSupport {

    private Response forge(Browser browser, Map<String, String> headers, Map<String, String> fields) throws Exception {
        return browser.post("/account/email", headers, form(fields, "email", "mallory@evil.example"));
    }

    @Test
    void crossSiteFormPostWithoutATokenIsRefused() throws Exception {
        Browser alice = signIn("alice");
        assertRefused(forge(alice, formFrom(EVIL, "cross-site"), Map.of()));
    }

    @Test
    void foreignOriginIsRefusedWhenTheBrowserSendsNoFetchMetadata() throws Exception {
        Browser alice = signIn("alice");
        assertRefused(forge(alice, Map.of("Origin", EVIL, "Referer", EVIL + "/"), Map.of()));
    }

    @Test
    void siblingSubdomainIsRefusedAlthoughSameSiteCookiesReachIt() throws Exception {
        Browser alice = signIn("alice");
        assertRefused(forge(alice, formFrom(SIBLING, "same-site"), Map.of()));
    }

    @Test
    void tokenFromTheAttackersOwnSessionIsRefused() throws Exception {
        Map<String, String> bobsFields = hiddenFields(signIn("bob").accountPage());
        Browser alice = signIn("alice");
        assertRefused(forge(alice, formFrom(EVIL, "cross-site"), bobsFields));
    }

    @Test
    void refererThatMerelyContainsTheHostNameIsRefused() throws Exception {
        Browser alice = signIn("alice");
        assertRefused(forge(alice, formFrom(LOOKALIKE, "cross-site"), Map.of()));
    }

    @Test
    void pageThatSuppressesTheRefererIsRefused() throws Exception {
        // <meta name="referrer" content="no-referrer"> on the attacking page: no Referer, Origin: null.
        Browser alice = signIn("alice");
        Map<String, String> headers = Map.of("Origin", "null", "Sec-Fetch-Site", "cross-site", "Sec-Fetch-Mode", "navigate", "Sec-Fetch-Dest", "document");
        assertRefused(forge(alice, headers, Map.of()));
    }

    @Test
    void crossSiteGetNavigationCannotChangeTheEmail() throws Exception {
        // Lax cookies are sent on top-level GET navigations, so only the method protects this.
        Browser alice = signIn("alice");
        Response res = alice.get("/account/email?email=mallory%40evil.example", linkFrom(EVIL));
        assertThat(accounts.emailOf("alice")).as("a GET changed Alice's email (HTTP %s)", res.status()).isEqualTo("alice@example.test");
        assertThat(res.status()).isGreaterThanOrEqualTo(400);
    }
}

Pitfalls

  • csrf.disable() is the usual way this breaks: it gets added when form posts or tests start failing with 403. Fix the client instead.
  • @RequestMapping without a method answers every HTTP method, and the CsrfFilter does not check GET, HEAD, TRACE or OPTIONS, so the handler can be driven with a plain link.
  • Spring Security checks only the token, not Origin or Sec-Fetch-Site (7.1.1 has no Fetch Metadata support), so a token that leaks, for example through a URL or a log, is accepted whichever site the request comes from.
  • The token is masked differently in every response (XorCsrfTokenRequestAttributeHandler, which protects against BREACH). A client that reads the raw token from the XSRF-TOKEN cookie needs csrf.spa() or a matching request handler.
  • The token lives in the HttpSession by default: when the session expires, posts fail with 403 until the page is reloaded.
  • With CSRF protection on, LogoutFilter only handles POST /logout; a GET logout link stops working by design.
  • Sign-in is protected too: the generated /login form carries _csrf, and CsrfAuthenticationStrategy replaces the token after authentication, so pages must render a fresh token after sign-in.
  • Semgrep's spring-csrf-disabled rule only knows the http.csrf().disable() chain that Spring Security 7 removed; use CodeQL java/spring-disabled-csrf-protection or a custom rule for the lambda form.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs csrf/java-spring does this for you).
  2. Run semgrep --config r/java.spring.security.unrestricted-request-mapping.unrestricted-request-mapping src/main/java and grep for csrf.disable and AbstractHttpConfigurer::disable; expect findings only in VulnerableSecurity and WrongSecurity.
  3. Against your own app, replay a forged post with a signed-in user's JSESSIONID and no token, for example curl -i -b 'JSESSIONID=...' -H 'Origin: https://evil.example' --data 'email=mallory@evil.example' https://your.app/account/email, and expect 403 with no change; repeat as a GET and expect 405.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix CSRF in Java and Spring Boot
ToolRuleWhat it found here
Semgrepjava.spring.security.unrestricted-request-mapping.unrestricted-request-mappingFlagged the @RequestMapping("/account/email") handler in VulnerableSecurity and in WrongSecurity; no findings in FixedSecurity, which uses @PostMapping.Checked: Semgrep 1.180.0 (CE)
Semgrepjava.spring.security.audit.spring-csrf-disabled.spring-csrf-disabledNo findings, although VulnerableSecurity and WrongSecurity disable CSRF protection. The pattern $OBJ.csrf(...).disable(...) matches only the chained http.csrf().disable(), which Spring Security 7 removed (HttpSecurity 7.1.1 has only csrf(Customizer)). On a probe file it flagged the chained form and missed csrf(csrf -> csrf.disable()) and csrf(AbstractHttpConfigurer::disable).Checked: Semgrep 1.180.0 (CE)
CodeQLjava/spring-disabled-csrf-protectionExpected to flag VulnerableSecurity and WrongSecurity: java/ql/lib/semmle/code/java/security/SpringCsrfProtection.qll matches disable() called on CsrfConfigurer<HttpSecurity>, which covers the lambda form, and csrf(AbstractHttpConfigurer::disable).Checked: github/codeql main at b5b8165; not executed
CodeQLjava/csrf-unprotected-request-typeExpected to flag updateEmail in VulnerableSecurity and WrongSecurity: the query treats @RequestMapping without a method, and @GetMapping, as unprotected, and reports such handlers when they reach a database update or when their name contains a state-changing word such as update (CsrfUnprotectedRequestTypeQuery.qll). An in-memory change in a method named, say, changeEmail would not be reported.Checked: github/codeql main at b5b8165; not executed
Find Security Bugs (SpotBugs plugin)SPRING_CSRF_PROTECTION_DISABLEDSpring CSRF protection disabledNot run on this card.Checked: Bug pattern listed on the Find Security Bugs site; not executed
Find Security Bugs (SpotBugs plugin)SPRING_CSRF_UNRESTRICTED_REQUEST_MAPPINGSpring CSRF unrestricted RequestMappingNot run on this card.Checked: Bug pattern listed on the Find Security Bugs site; not executed

Libraries and versions

Libraries and versions
ComponentVersion
LanguageJava 21
RuntimeOpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16
FrameworkSpring Boot 4.1.1 (Spring Security 7.1.1, Spring Framework 7.0.9, Spring MVC)
org.springframework.boot:spring-boot-starter-parent4.1.1
org.springframework.boot:spring-boot-starter-webmvc4.1.1
org.springframework.boot:spring-boot-starter-security4.1.1 (Spring Security 7.1.1)
org.apache.tomcat.embed:tomcat-embed-core (overrides Boot's 11.0.24)11.0.26
tools.jackson:jackson-bom (overrides Boot's 3.1.5)3.1.7
org.springframework.boot:spring-boot-starter-test4.1.1
org.junit.jupiter:junit-jupiter (managed by Spring Boot)6.0.3

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the csrf/java-springfolder. Maven runs with JDK 21 (set JAVA_HOME).

Shell
mvn -B -q test
Shell
# Expected: every test passes
IMPL=fixed mvn -B -o -q test

# Expected: an exploit test fails
IMPL=vulnerable mvn -B -o -q test

# Expected: a test fails
IMPL=wrong mvn -B -o -q test

Each run writes a JUnit report to target/surefire-reports/TEST-*.xml. From the top folder of the download, node run-all.mjs csrf/java-spring runs all three modes and checks the expected results.

Download the project ZIP, 26 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • java OpenJDK 21.0.12.1 (Homebrew)
  • maven 3.9.16
  • springBoot 4.1.1
  • springSecurity 7.1.1
  • tomcat 11.0.26
  • junitJupiter 6.0.3
Matrix
fixed: 12 of 12 tests pass; vulnerable: 7 of 12 tests fail (7 exploit tests); wrong: 3 of 12 tests fail (3 exploit tests)
Test results
fixed 12/12 pass; vulnerable 7/12 fail (all 7 exploit tests); wrong 3/12 fail (3 exploit tests), offline after install
Dependency freshness
Spring Boot 4.1.1 and Spring Security 7.1.1 were published on 2026-08-20. Boot 4.1.1 manages Tomcat 11.0.24 and Jackson 3.1.5, which OSV lists with advisories fixed in Tomcat 11.0.26 and Jackson 3.1.7 (GHSA-9xv2-5v5q-p794, GHSA-gcx9-497g-6cp6, GHSA-h3x4-894j-xpx5; GHSA-cxp5-3px4-pw24 and others), so the pom overrides tomcat.version to 11.0.26 (2026-09-09) and jackson-bom.version to 3.1.7 (2026-09-21). OSV listed no advisories for the 74 resolved artifacts after the overrides. Maven has no lockfile; the Boot parent and the two overrides fix every version.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. OWASP Cross-Site Request Forgery Prevention Cheat Sheet
  2. Spring Security: Cross Site Request Forgery (CSRF) for servlet applications
  3. Spring Security: CSRF protection concepts (safe methods, when to use CSRF protection)
  4. Spring Security: form login
  5. Spring Boot: common application properties (server.servlet.session.cookie.*)
  6. Find Security Bugs: SPRING_CSRF_UNRESTRICTED_REQUEST_MAPPING
  7. MDN: Set-Cookie (SameSite)

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.