CSRF: Cross-Site Request Forgery
CSRF protection is on by default in Spring Security for a specific reason tied to cookies — and disabling it globally to "fix" a 403 is one of the most common security regressions teams introduce.
Learning objectives
- Beginner: Explain why CSRF specifically targets cookie-based session authentication, not token-based auth.
- Intermediate: Wire up CSRF token handling correctly for an Angular/SPA client.
- Advanced: Justify disabling CSRF only for specific stateless endpoints, rather than globally for the whole application.
🌱 BEGINNER STORY
Imagine you're logged into your bank's website in one browser tab (your browser now holds a valid session cookie for bank.com). In another tab, you visit a completely unrelated, malicious website. That malicious page contains a hidden, invisible form that auto-submits a POST request to bank.com/transfer-funds the instant the page loads. Here's the scary part: your browser DOESN'T know or care that the request originated from a shady site — it automatically attaches your bank.com session cookie to ANY request going to bank.com, exactly as it would if you'd clicked a legitimate 'Transfer' button yourself. From the bank server's point of view, it looks like a perfectly valid, authenticated request. That's Cross-Site Request Forgery: the attacker doesn't need to see or steal your cookie — they just trick your OWN browser into using it on their behalf.
Figure: Left: a forged cross-site request rides on the victim's own cookie. Right: a CSRF token that the attacker's site cannot read or guess blocks the forgery.
CSRF specifically exploits the fact that browsers AUTOMATICALLY attach cookies to requests, regardless of which page/script initiated the request. If your API instead uses a Bearer token in an Authorization header (like JWT), there's nothing for the browser to 'automatically attach' — the malicious page has no way to read your token (it's not a cookie, and same-origin policy blocks reading it from your app's storage) and therefore cannot forge a valid request. This is precisely why many stateless REST APIs disable CSRF protection — but ONLY once they've confirmed they truly don't rely on cookies for authentication.
Spring Security's default CSRF protection issues a unique, unpredictable, per-session (or per-request) CSRF token, and requires that EVERY state-changing request (POST, PUT, DELETE, PATCH) include that exact token — typically as a hidden form field (for server-rendered HTML) or a custom header like X-XSRF-TOKEN (for SPA/AJAX clients). Since the attacker's malicious page has no way to read this token (browsers block cross-origin reads of another site's response body/cookies-by-JS depending on flags), the forged request is missing (or has an invalid) token and gets rejected with HTTP 403.
// Backend: expose the CSRF token as a readable cookie for the SPA to read and echo back
@Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.csrf(csrf -> csrf .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) .ignoringRequestMatchers("/api/public/", "/api/webhook/")); return http.build(); }
withHttpOnlyFalse() is deliberate here: normally HttpOnly cookies can't be read by JavaScript (a good thing for session cookies, to block XSS-based theft) — but the CSRF token cookie is DESIGNED to be read by your own frontend JavaScript so it can echo the value back in a custom header. This works because the security relies not on secrecy of the value from your own site, but on the fact that a DIFFERENT (attacker) site cannot read cookies belonging to your domain due to Same-Origin Policy.
// Angular HttpClient (with HttpClientXsrfModule configured) automatically
// reads the XSRF-TOKEN cookie and attaches it as X-XSRF-TOKEN header on // every state-changing request -- this is built-in Angular behavior that // pairs naturally with Spring Security's CookieCsrfTokenRepository.
Some endpoints legitimately don't need CSRF protection: public registration forms with no prior session, third-party webhook receivers (Stripe, GitHub) that can't supply your CSRF token at all, and stateless JWT-secured APIs. Use ignoringRequestMatchers() precisely and narrowly — never disable CSRF globally 'to make testing easier' and forget to re-enable it before shipping a cookie-based application to production.
⚠ Common mistakes and subtle traps
Disabling CSRF entirely (csrf.disable()) is common advice found online for 'quick fixes' during REST API development — this is only safe if the API is TRULY stateless (JWT/OAuth2 bearer tokens, no session cookie). Disabling it on a cookie/session-based app removes real protection. A GET request that has SIDE EFFECTS (e.g., GET /api/deleteAccount?id=5 — a bad REST design in itself) bypasses CSRF protection entirely, since Spring Security's default CSRF filter only protects state-changing HTTP methods (POST/PUT/DELETE/PATCH) by design, assuming GET is safe/idempotent per HTTP semantics. SameSite cookie attributes (Lax/Strict) provide a complementary, browser-level defense against CSRF and are worth configuring alongside (not instead of) Spring Security's token-based CSRF protection, since older browsers or specific request types can still be vulnerable without both layers. Multi-tab or multi-window session behavior can cause 'stale CSRF token' errors for users who leave a form open for a long time across a session refresh — handle this gracefully by re-fetching a fresh token on 403 CSRF failures rather than showing an unexplained error.
Want a visual for this concept?
Generate a diagram tailored to “CSRF: Cross-Site Request Forgery” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →