Cross-site request forgery (CSRF) lets another website make a signed-in user's browser send a state-changing request that the server cannot tell apart from a real one. The weakness exists wherever an application authenticates requests only with credentials the browser attaches automatically, such as session cookies or HTTP authentication. This guide explains how the attack works, where it still appears despite SameSite defaults, and how to layer framework tokens, Fetch Metadata checks, origin verification and cookie settings so that one gap does not become an incident. It is written for developers, application security engineers and testers. For the wider family of web attacks see the Web Application Attacks Guide, and for cookie and header values see the Security Headers Guide.
Key takeaways
- Use the framework's built-in protection first. Django, Spring Security, ASP.NET Core, Rails and Go 1.25 all ship CSRF defences; most real failures come from exemptions and disabled middleware, not from missing code.
- Fetch Metadata is now the primary signal. OWASP recommends rejecting unsafe methods when
Sec-Fetch-Siteiscross-site, with origin checks or tokens as a mandatory fallback for clients that omit the header. - SameSite is defence in depth, not the fix.
Laxstill sends cookies on top-level GET navigations, and sibling subdomains count as same-site. - Never change state with GET. Every framework treats GET, HEAD and OPTIONS as safe and skips CSRF checks on them.
- XSS defeats every CSRF control. Script running in your origin can read tokens and send same-origin requests, so CSRF work depends on XSS prevention.
How the attack works
MITRE CWE-352 describes the weakness as an application that does not, or cannot, sufficiently verify whether a request was intentionally sent by the user who issued it. Alternate names are session riding and XSRF. The attack pattern is catalogued as CAPEC-62.
- Ambient credentials: the victim is signed in to the target application, which tracks the session with a cookie.
- Attacker-controlled page: the victim visits a page or opens an email that the attacker controls.
- Forged request: that page causes the browser to send a request to the target, for example a form submission. The browser attaches the target's cookies according to their SameSite setting.
- Server accepts: if the server checks only the cookie, it performs the action with the victim's privileges. The attacker never sees the response; the damage is the state change itself.
The attacker needs only to predict the request: URL, parameters and values. OWASP WSTG-SESS-05 notes that switching from GET to POST does not prevent CSRF, because forms and scripts can submit POST requests across sites.
Variants worth naming
| Variant | What happens | Primary control |
|---|---|---|
| Classic CSRF | Cross-site form or navigation triggers a state change with the session cookie | Framework token or Fetch Metadata check |
| Login CSRF | The victim is signed in to the attacker's account, so later activity (searches, saved cards, uploads) lands in an account the attacker can read | Token on the login form via a pre-session, new session ID after login |
| Client-side CSRF | Your own JavaScript builds a request from attacker-controlled input such as the URL fragment or postMessage data; the request is same-origin, so tokens and SameSite do not help | Do not build request targets from untrusted input; allow-list endpoints and methods |
| Subdomain-assisted CSRF | A compromised or taken-over sibling subdomain is same-site and can plant cookies on the parent domain | Signed tokens, __Host- cookies, treat same-site as untrusted |
| OAuth callback CSRF | A forged authorisation response links the victim to the attacker's identity or token | PKCE or state validation (ASVS V10.2.1) |
Where it shows up
- Exempted endpoints: views marked
csrf_exemptin Django,ignoringRequestMatchersin Spring,[IgnoreAntiforgeryToken]or.DisableAntiforgery()in ASP.NET Core, often added to fix a broken integration and never revisited. - Disabled protection on "APIs" that use cookies: Spring's documentation says to disable CSRF only for stateless APIs whose non-browser clients do not use cookie authentication. A single-page app talking to its own API with a session cookie is not that case.
- State-changing GET handlers: logout, unsubscribe, "approve" links and legacy admin actions. Go's
CrossOriginProtectionalways allows GET, HEAD and OPTIONS, as do the other frameworks' defaults. - APIs accepting simple content types: an endpoint that accepts
application/x-www-form-urlencoded,multipart/form-dataortext/plaincan be reached by a plain cross-site form without a CORS preflight. ASVS V3.5.2 requires that preflight-dependent functionality cannot be called without one. - Internal applications: WSTG notes that intranet tools are reachable because the victim's browser can reach them even when the attacker cannot. Routers, printers and admin consoles with default cookie settings are common examples.
- Self-vulnerable applications: webmail, forums and ticketing systems where content viewed inside the application can itself trigger requests.
Impact
CWE-352 lists gaining privileges or assuming identity, bypassing protection mechanisms, reading or modifying data and denial of service. The attacker's reach is limited only by the victim's privileges, so a forged request against an administrator can mean full application compromise.
| Target action | Typical consequence |
|---|---|
| Change email address or recovery phone | Account takeover through the password reset flow. See the Account Takeover Prevention Guide. |
| Create a user, add a role or register an API key | Persistent access that outlives the victim's session |
| Add an OAuth client, webhook or SSO connection | A new machine identity under attacker control |
| Payments, transfers, orders | Direct financial loss |
| Security settings (disable MFA, open IP allow-list) | Weakened defences ahead of a second-stage attack |
The identity angle is real here: admin consoles that mint API keys, service accounts or integration tokens turn one forged request into a long-lived non-human credential. Protect those endpoints with re-authentication as well as CSRF tokens, and review them as part of the Token and Session Security Guide controls.
Detection
CSRF requests look legitimate at the application layer, so detection relies on the signals the defences produce and on testing.
- Validation failures: log every rejected token or Fetch Metadata decision with endpoint, method,
Sec-Fetch-Site,Originand user. Django returns 403 throughCSRF_FAILURE_VIEW; Spring raisesInvalidCsrfTokenException; ASP.NET Core minimal APIs return 400 on failed form validation. A spike on one endpoint for many users suggests a live campaign; a spike after a release suggests a bug. - Report-only Fetch Metadata: web.dev and OWASP both recommend running a resource isolation policy in logging mode first. The same logs become a detection feed once enforced.
- Sensitive change telemetry: alert on email, MFA, role and API key changes where
Originis absent,nullor foreign. - Testing: WSTG-SESS-05 asks testers to confirm whether sessions rely only on automatically sent credentials and to retest in a browser that does not default to
Lax, because a failed attempt in one browser proves nothing. Spring's test support (csrf().useInvalidToken()) lets teams assert a 403 in unit tests. See the Web Application Security Testing Guide. - Code review: grep for exemption markers (
csrf_exempt,csrf.disable(),ignoringRequestMatchers,IgnoreAntiforgeryToken,DisableAntiforgery,skip_forgery_protection,AddInsecureBypassPattern) and require a justification for each.
Metrics
- Number of CSRF exemptions per application, with an owner and review date for each (target: falling).
- Percentage of state-changing routes covered by framework validation, measured from route inventories.
- Session cookies with explicit SameSite and a
__Host-prefix, as a share of all session cookies. - State-changing GET handlers found in review or DAST (target: zero).
- Rejected requests per 10,000 unsafe requests after enforcement, split by legitimate breakage and attack.
Prevention and hardening
Choosing the control
| Control | How it works | Best fit | Limits |
|---|---|---|---|
| Synchroniser token | Server-generated secret tied to the session, sent in a hidden field or custom header, checked on every unsafe request | Server-rendered, stateful apps | Per-request tokens can break the Back button; never put tokens in URLs or a cookie for this pattern |
| Signed double-submit cookie | HMAC-SHA-256 over a session-dependent value and a random value, sent as cookie and as header or field, compared in constant time | Stateless apps and horizontally scaled services | Must be bound to session data that changes at login; the naive unsigned variant is insecure |
Fetch Metadata (Sec-Fetch-Site) | Reject unsafe methods when the browser reports cross-site | Any HTTPS app; low effort, central enforcement | Needs a fallback for clients that omit the header; HTTPS only |
| Origin and Referer verification | Compare the full origin with the expected value from configuration | Fallback for Fetch Metadata | Missing or null must fail closed for sensitive actions; Referrer-Policy: no-referrer can break it |
| Custom request header | Require a non-safelisted header so cross-origin calls need a CORS preflight | JSON APIs called by your own front end | Breaks if CORS allows credentials from broad or regex origins |
SameSite Lax or Strict | Browser withholds the cookie on cross-site subrequests | Every session cookie, as a second layer | Same-site siblings, top-level GET, client-side CSRF, browser defaults vary |
| Re-authentication or one-time code | User proves presence for a high-impact action | Password, email, MFA, payments, key creation | Friction; OWASP says CAPTCHA is not a CSRF defence |
Framework settings to know
| Framework | Default behaviour | Settings and markers |
|---|---|---|
| Django | CsrfViewMiddleware rejects unsafe methods without a valid token; masks the token per response against BREACH; checks Origin, then strict Referer on HTTPS | csrfmiddlewaretoken, X-CSRFToken header, CSRF_TRUSTED_ORIGINS, CSRF_USE_SESSIONS, csrf_exempt |
| Spring Security | Enabled by default for unsafe methods; token in HttpSessionCsrfTokenRepository; XorCsrfTokenRequestAttributeHandler adds BREACH protection | _csrf parameter, X-CSRF-TOKEN / X-XSRF-TOKEN, CookieCsrfTokenRepository, csrf.spa(), ignoringRequestMatchers |
| ASP.NET Core | Synchroniser tokens via Data Protection; form tag helper adds __RequestVerificationToken; minimal API middleware validates POST, PUT and PATCH | [AutoValidateAntiforgeryToken], [ValidateAntiForgeryToken], UseAntiforgery() after authentication and authorisation, HeaderName |
| Rails | protect_from_forgery enabled for new apps; token in forms and meta tags | with: :exception, csrf_meta_tags, authenticity_token, X-CSRF-Token |
| Go 1.25+ | http.CrossOriginProtection checks Sec-Fetch-Site, then Origin against Host; returns 403 | AddTrustedOrigin, AddInsecureBypassPattern, SetDenyHandler |
Two details catch teams out. In ASP.NET Core, AddControllers does not enable antiforgery; use AddControllersWithViews or add the filter. In minimal APIs, DELETE is not validated by the middleware, so call IAntiforgery.ValidateRequestAsync explicitly. Go's CrossOriginProtection allows requests that carry neither Sec-Fetch-Site nor Origin, treating them as same-origin or non-browser, so pair it with authentication that is not cookie-based for those clients.
When to choose what
- Server-rendered monolith: keep the framework token on, add Fetch Metadata at the edge or middleware, set session cookies
SameSite=Lax(orStrictfor admin sessions). - Single-page app with a cookie session: use the framework's SPA mode (for example Spring
csrf.spa()) or a signed double-submit token sent as a header, require JSON, and reject simple content types. - API with bearer tokens in the
Authorizationheader: Microsoft notes the browser does not attach these automatically, so CSRF does not apply. The trade-off is that tokens in local storage are readable by injected script; see the Session Hijacking and Cookie Theft Guide. - Small team, many legacy apps: a central Fetch Metadata policy at a reverse proxy gives broad coverage quickly. Large organisations should also standardise a hardened framework baseline and fail builds that add unreviewed exemptions.
Implementation sequence
- Inventory: list every state-changing route, its method, authentication type and any exemption. Remove state changes from GET handlers first.
- Restore framework defaults: re-enable middleware, convert exemptions to narrowly scoped ones with an owner.
- Cookies: set session and CSRF cookies to
__Host-,Secure, explicitSameSite, noDomain. Do not rely on browser Lax-by-default; MDN says only some browsers apply it, with a two-minute POST allowance. - Fetch Metadata in log-only mode: allow
same-originandnone, decide onsame-site, allow top-level GET navigations, log the rest. AddVary: Sec-Fetch-Dest, Sec-Fetch-Mode, Sec-Fetch-Sitewhere responses differ. - Pilot: enforce on one application and watch cross-site flows such as payment returns, SSO callbacks and partner form posts; exempt only those, by exact origin.
- Enforce and fall back: reject with 403; for requests without Fetch Metadata, verify
Originor the token, and fail closed on sensitive endpoints. - Step-up: add re-authentication for password, email, MFA and key creation.
- Rollback: keep the policy behind a configuration flag so enforcement can return to log-only in minutes, without removing tokens.
Common failure patterns
- Exemption creep: an endpoint is exempted to fix a partner integration or webhook and later gains a cookie-authenticated browser use. Scope exemptions to exact routes and review them each release.
- Naive double-submit: an unsigned cookie compared with a header. OWASP calls cookie-only validation insecure because a sibling subdomain or plain-HTTP response can set the cookie.
- Trusting a missing Origin: treating absent or
nullOriginas same-origin to avoid breaking clients. OWASP says not to bypass validation for compatibility. - Relying on browser defaults: assuming Lax-by-default makes tokens unnecessary. Defaults differ by browser and the two-minute POST allowance leaves a window.
- Suffix or regex origin matching: a check that accepts any origin ending in the company domain also accepts look-alike or taken-over hosts. Match the full origin from configuration.
- Disabled Referer: a site-wide
Referrer-Policy: no-referrerbreaks Django's strict Referer check on HTTPS; userel="noreferrer"on outbound links instead.
Framework mapping
| Framework | Reference | Relevance |
|---|---|---|
| OWASP ASVS 5.0 | V3.5.1, V3.5.2, V3.5.3, V3.3.2, V10.2.1 | Anti-forgery tokens or non-safelisted headers, preflight cannot be bypassed, no sensitive actions on safe methods or strict Sec-Fetch validation, SameSite by purpose, OAuth client CSRF |
| MITRE CWE / CAPEC | CWE-352, CWE-1275; CAPEC-62 | Weakness, improper SameSite attribute and attack pattern |
| NIST SP 800-53 Rev. 5 | SC-23 Session Authenticity; SI-10 Information Input Validation; SA-11 Developer Testing and Evaluation | Protecting session authenticity, validating requests, testing |
| NIST CSF 2.0 | PR.PS-06 | Secure software development practices |
| ISO/IEC 27001:2022 | A.8.28 Secure coding; A.8.29 Security testing in development and acceptance | Coding standards and testing |
| CIS Controls v8.1 | Control 16 Application Software Security | Secure development practices |
Practitioner checklist
- Confirm framework CSRF protection is enabled in every application and that each exemption has an owner and justification.
- Remove all state changes from GET, HEAD and OPTIONS handlers.
- Use synchroniser tokens or HMAC-signed, session-bound double-submit tokens; never the naive unsigned pattern.
- Add a Fetch Metadata policy, rolled out in log-only mode, with Origin verification as fallback.
- Reject simple content types on JSON APIs and never combine credentialed CORS with wildcard or regex origins.
- Set session cookies
__Host-,Secure,HttpOnlyand explicitSameSite. - Protect login forms with a pre-session token and issue a new session ID after authentication.
- Validate OAuth
stateor use PKCE on every authorisation code flow. - Require re-authentication for email, MFA, role and API key changes.
- Test with WSTG-SESS-05 in more than one browser and add negative CSRF tests to CI.
Standards and references
- OWASP: Cross-Site Request Forgery Prevention Cheat Sheet
- MITRE: CWE-352 Cross-Site Request Forgery (CSRF)
- OWASP: Application Security Verification Standard 5.0
- OWASP: WSTG Testing for Cross Site Request Forgery
- MDN: Sec-Fetch-Site
- MDN: Set-Cookie (SameSite and cookie prefixes)
- Spring Security: Cross Site Request Forgery (CSRF)
- Microsoft Learn: Prevent Cross-Site Request Forgery attacks in ASP.NET Core
Related NHI Mgmt Group resources: Web Application Attacks Guide · Security Headers Guide · Cross-Site Scripting (XSS) Prevention Guide · OAuth 2.0 and OpenID Connect Guide · Secure Coding Guide