CSRF (cross-site request forgery) is an attack where a page on another site makes your browser send a request to a site you are logged in to, and the browser attaches your cookies automatically, so the server treats the forged request as yours. Sites stop it by requiring something an attacker's page cannot supply: a secret CSRF token in each state-changing request, or a check of the browser's Sec-Fetch-Site header, with SameSite cookies as an extra layer.
The attacker never sees your cookies or the response. They only need the request to happen. That asymmetry comes from the browser's same-origin policy: MDN describes cross-origin writes such as form submissions as "typically allowed" and cross-origin reads as "typically disallowed".
How a CSRF attack works
MDN's example is a bank with a transfer form. The attacker builds a page with a copy of that form, pointed at the real bank:
<form action="https://my-bank.example.org/transfer" method="POST">
<input type="hidden" name="recipient" value="attacker" />
<input type="hidden" name="amount" value="1000" />
</form>
<script>
document.querySelector("form").submit();
</script>
The steps:
- You log in to
my-bank.example.org. The bank sets a session cookie. - Later, in the same browser, you open the attacker's page (from an email, an ad, a forum link).
- The form submits itself to the bank. Your browser includes the bank's session cookie, because cookies are attached by destination, not by which page started the request.
- The bank sees a valid session and a well-formed transfer, and carries it out.
With a GET endpoint it is even simpler. An image tag is enough: <img src="https://my-bank.example.org/transfer?recipient=attacker&amount=1000">.
When is a site vulnerable to CSRF?
MDN gives three conditions. All three must hold:
- The site uses HTTP requests to change state (transfer money, change email, delete a repository).
- It authenticates those requests only with credentials the browser adds automatically, usually cookies.
- Every parameter in the request is predictable to an attacker.
Break any one and the attack fails. Each defence below breaks one of them.
CSRF vs XSS
| CSRF | XSS | |
|---|---|---|
| Where attacker code runs | On the attacker's site | Inside your site |
| Can read your pages and responses | No | Yes |
| What it abuses | Cookies being sent automatically | Unescaped output in your HTML |
| Main fix | CSRF token or Sec-Fetch-Site check | Context-aware output encoding, CSP |
The two interact in one direction. The OWASP cheat sheet: "Remember that Cross-Site Scripting (XSS) can defeat all CSRF mitigation techniques!" A script running inside your origin can read the token from the page and send it. See what XSS is for that side.
How to prevent CSRF
1. CSRF tokens (synchronizer token pattern)
The server generates a random, unpredictable token per session, embeds it in each form as a hidden field, and rejects any state-changing request where the token is missing or wrong:
<form action="/transfer" method="POST">
<input type="hidden" name="csrf_token" value="6f1c2b9e8a...random...">
...
</form>
The attacker's page can make your browser send cookies, but it cannot read your bank's HTML to learn the token, so its forged form has no valid value. For JavaScript requests the token usually travels in a header such as X-CSRF-Token. OWASP says tokens "should be generated on the server-side" and must be secret and unpredictable. Most web frameworks include this; Django is one MDN mentions by name. Use the built-in version rather than writing your own.
Stateless apps can use the signed double-submit cookie pattern instead: the token is in a cookie and in the request, and the server checks that they match and that the HMAC signature, bound to the session, is valid. OWASP warns that the unsigned, naive version can be defeated by cookie injection.
2. Check the Sec-Fetch-Site header
Modern browsers add a Sec-Fetch-Site header to requests saying whether they came from the same origin, the same site, another site, or directly from the user (typing a URL, a bookmark). OWASP now calls it "the primary signal for CSRF protection". MDN's Express example accepts state changes only from your own site:
app.post("/transfer", (req, res) => {
const secFetchSite = req.headers["sec-fetch-site"];
if (secFetchSite === "same-origin" || secFetchSite === "same-site") {
// update state
} else {
// reject
}
});
OWASP adds that a fallback "is a mandatory requirement", because some older browsers don't send the header. The usual fallback is checking the Origin header (or Referer) against your own origin, and rejecting the request if neither establishes a trusted source. Don't allowlist the value null.
3. SameSite cookies
The SameSite cookie attribute controls whether a cookie is sent on cross-site requests:
| Value | Sent on cross-site requests? | Trade-off |
|---|---|---|
Strict | Never | Following a link to your site from elsewhere arrives logged out |
Lax | Only on top-level navigations with a safe method (GET, not POST) | Blocks forged POST forms; GET state changes stay exposed |
None | Always (requires Secure) | No CSRF protection from the cookie |
A session cookie set this way blocks the auto-submitting form above in browsers that honour it:
Set-Cookie: session=abc123; Secure; HttpOnly; SameSite=Lax
MDN notes that some browsers use Lax by default when SameSite is missing, and that this default is looser: cookies set in the last two minutes are still sent on cross-site POSTs. Set it explicitly. Both OWASP and MDN treat SameSite as defence in depth, not the main fix. Two reasons: Lax allows top-level GETs, and SameSite compares sites, not origins, so blog.example.org counts as the same site as bank.example.org.
4. Never change state with GET
OWASP: "Do not use GET requests for state changing operations." GET requests are triggered by images, links and prefetchers, and SameSite=Lax lets top-level GETs through. Logout, delete and "approve" buttons should all be POST (or PUT, PATCH, DELETE) behind one of the defences above. MDN also warns about framework "method override" features that let a GET with _method=POST act like a POST.
5. JSON APIs and custom headers
An HTML form can only send a few content types cross-site. A request with Content-Type: application/json or a custom header triggers a CORS preflight, which the attacker's origin fails unless your server allows it. OWASP accepts a required custom header as a defence for API endpoints. It only holds if your server rejects form-encoded bodies on those endpoints and your CORS policy does not reflect arbitrary origins with Access-Control-Allow-Credentials: true.
A quick CSRF self-check
- List every endpoint that changes data and relies on a session cookie.
- Confirm each one rejects requests without a valid token, or with a cross-site
Sec-Fetch-Sitevalue. Test it: submit the form from a local HTML file on another origin and confirm the server refuses it. - Check that no state change answers to GET.
- Set
SameSiteon session cookies explicitly:Strictwhere you can,Laxwhere you must. - Protect the login form too. Login CSRF signs the victim in to the attacker's account; OWASP recommends a token tied to a pre-session that is replaced after login.
If requests fail after adding CSRF protection
A 403 on every POST after enabling a framework's CSRF middleware usually means the token isn't reaching the server: the template forgot to render the hidden field, a JavaScript client isn't copying the token into the expected header, or a cached page is serving an old token. Check the request in the browser's network tab and compare the field or header name with what the framework expects. If the 403 only happens from another domain you control, that is the protection working; allow that origin explicitly. For what the status code itself means, see HTTP status codes explained.
Frequently asked questions
- Do I need CSRF protection for a JSON API?
- It depends on how the API authenticates. If the browser sends credentials automatically (cookies, HTTP basic auth), yes. If every request carries a token your JavaScript adds explicitly, such as an Authorization: Bearer header read from memory, a forged cross-site request has no way to include it, so classic CSRF does not apply. Also make sure the API rejects form-encoded bodies and that CORS does not allow arbitrary origins with credentials.
- Is SameSite=Lax enough to stop CSRF?
- Not on its own. Lax still sends cookies on top-level GET navigations, so any state change reachable by GET is exposed, and SameSite treats all subdomains of a site as the same site. OWASP and MDN both describe SameSite as defence in depth to use alongside a CSRF token or a Sec-Fetch-Site check.
- What is login CSRF?
- Login CSRF forges a login request so the victim ends up signed in to the attacker's account. Anything the victim then enters, such as a card number or search history, is saved where the attacker can see it. OWASP recommends protecting login forms with a token tied to a pre-session, then replacing that session after authentication.
- Can CSRF read data from my site?
- No. CSRF makes the victim's browser send a request, but the browser's same-origin policy stops the attacker's page from reading the response. The danger is in what the request does: transfers, password or email changes, deletions. Reading data across origins needs a different bug, such as XSS or a misconfigured CORS policy.