Cross-site request forgery (CSRF) makes a victim's browser send a request the victim never meant to send. The attacker does not steal the session; they borrow it. A page on another site submits a form or starts a request to your application, the browser attaches the victim's cookies as it always does, and the server treats the result as a deliberate action by a signed-in user.
The OWASP Top 10:2025 maps CWE-352 to A01 Broken Access Control, and that placement fits: the server authorizes the right person but never checks that the person intended the action. SelfSec reports a POST form without an anti-CSRF token field as CWE-352 at medium severity.
The mental model: the browser adds the credentials
Cookies are ambient credentials. The browser decides when to send them, and historically it sent them on every request to the cookie's site, no matter which page started it. HTTP Basic authentication and client certificates behave the same way.
| Part of the forged request |
Who controls it |
| Target URL and method |
The attacker's page |
| Body parameters |
The attacker's page |
| Session cookie |
The victim's browser, added automatically |
| The response |
Nobody on the attacker's side: the same-origin policy hides it |
That last row is why CSRF is a write primitive. The attacker cannot read what comes back, but an email change, a transfer or a new administrator account does not need to be read to be useful.
How it happens
PortSwigger describes three conditions: an action worth triggering, session handling based only on cookies, and no request parameter the attacker cannot guess. A typical handler meets all three:
app.post('/account/email', requireLogin, async (req, res) => {
await users.updateEmail(req.session.userId, req.body.email);
res.redirect('/account');
});
requireLogin proves that the request carries a valid session cookie. It cannot prove that the user's own page sent it, because a cross-site form submission carries the same cookie.
A concrete test: forge one harmless change
An authorized tester needs two test accounts and a page on an origin they control, such as https://tester.example. Sign in to the first account, change its email once, and capture the request:
POST /account/email HTTP/1.1
Host: app.example
Cookie: session=4c1d9a0e
Content-Type: application/x-www-form-urlencoded
email=victim%40mail.example
Nothing in it is secret except the cookie, and the attacker never needs the cookie. The proof-of-concept page rebuilds the form and submits it:
<form id="poc" method="post" action="https://app.example/account/email">
<input type="hidden" name="email" value="[email protected]">
</form>
<script>document.getElementById('poc').submit();</script>
Open it in the same browser while signed in. If the account now shows [email protected], the endpoint is forgeable; restore the address and stop.
If the request does carry a token, the same page tests how it is enforced: drop the field, substitute a token from the second account's session, resend the action as a GET, or change the body to text/plain. Any success is the same vulnerability. A test that fails only because the browser withheld a cookie with no SameSite attribute is still a finding, since that protection belongs to one browser's default rather than to the server.
What an attacker gains
- Account takeover: change the email or recovery phone, then reset the password.
- Unwanted transactions: transfers, purchases, subscription or shipping changes.
- Privilege changes: an administrator's browser creates a user or grants a role.
- Quiet data routing: a new webhook URL, forwarding rule or API key the attacker reads later.
- Login CSRF: the victim is signed in to the attacker's account, so whatever they save lands where the attacker can read it.
The same ambient-cookie problem applies to WebSocket handshakes, covered in cross-site WebSocket hijacking, and to GraphQL endpoints that accept form-encoded mutations, covered in GraphQL API security.
Reading a SelfSec CSRF finding
The check is passive. It flags POST forms that have no hidden field named exactly like a common token, such as _csrf, csrfmiddlewaretoken, authenticity_token or __RequestVerificationToken, and it never submits a forged request, so the finding stays possible rather than confirmed. A form protected by a header token or a custom field name is still flagged, and so is a POST endpoint found in a script or an API definition, which has no hidden fields at all; for those, check whether the server demands a JSON content type or a custom header. A correctly named field proves only that the name is present, not that the server validates it. Run the two-account test above to settle it.
The fix: require proof the request came from your pages
The server needs evidence of intent. Three mechanisms supply it and combine well: a secret token, origin headers set by the browser, and cookies the browser withholds from cross-site requests.
A synchronizer token is a random value bound to the session, rendered into each form and checked on every unsafe request. Use the framework's implementation. In ASP.NET Core MVC, validate it globally so a new action cannot ship unprotected:
using Microsoft.AspNetCore.Mvc;
builder.Services.AddControllersWithViews(options =>
{
options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute());
});
The form tag helper adds a hidden __RequestVerificationToken field to method="post" forms, the filter skips only GET, HEAD, OPTIONS and TRACE, and Razor Pages validate by default. Minimal APIs use builder.Services.AddAntiforgery() with app.UseAntiforgery(), which validates endpoints that bind form data.
Django enables CsrfViewMiddleware by default; the template still has to render the token in every internal POST form:
<form method="post" action="{% url 'account-email' %}">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Update email</button>
</form>
Script requests send the same value in the X-CSRFToken header. Treat every @csrf_exempt as a decision someone must justify.
Modern browsers also label requests to HTTPS sites with Sec-Fetch-Site, and Go 1.25 builds a cross-origin check on it, falling back to comparing Origin with Host:
mux := http.NewServeMux()
mux.HandleFunc("POST /account/email", changeEmail)
protection := http.NewCrossOriginProtection()
if err := protection.AddTrustedOrigin("https://admin.example.com"); err != nil {
log.Fatal(err)
}
log.Fatal(http.ListenAndServe(":8080", protection.Handler(mux)))
Cross-origin POST, PUT, PATCH and DELETE requests get a 403, while GET, HEAD and OPTIONS pass, so those methods must never change state. Then set the session cookie's attributes explicitly:
Set-Cookie: __Host-session=9f1c0e7a; Path=/; Secure; HttpOnly; SameSite=Lax
SameSite=Lax keeps the cookie off cross-site POSTs but still sends it on top-level GET navigations; Strict withholds it even when a user follows a link from another site. Both treat sibling subdomains as same-site, which is why the cookie is defense in depth. For JSON APIs on cookie sessions, accept only application/json and require a custom header such as X-CSRF-Token, which a form cannot set and a cross-origin script cannot send without passing CORS preflight.
Fixes that do not hold
- Relying on the browser's default: not every browser applies
Lax when the attribute is missing, and Chrome's default still sends a newly set cookie on top-level cross-site POSTs for two minutes.
- Accepting only POST: forms submit POST across sites without any script.
- Treating CORS as protection: CORS decides who may read a response, not whether a simple request is sent. See CORS misconfiguration.
- JSON by convention: a parser that also accepts
text/plain or form-encoded bodies is forgeable from a form.
- Tokens that are not enforced: validated only when present, not bound to the session, or skipped when the same action is reached by GET.
- Loose Referer checks: an attacker can suppress the header, and a prefix match accepts
https://app.example.attacker.example.
- Naive double-submit cookies: a sibling subdomain that can set cookies can plant a matching pair; bind the value to the session with an HMAC.
- Ignoring XSS: script running on your origin reads tokens and sends same-origin requests, so cross-site scripting defeats every CSRF defense.
A developer review checklist
- List every state-changing endpoint, including login and logout, and confirm none runs on GET.
- Enable the framework's CSRF protection globally and review each exemption (
csrf_exempt, IgnoreAntiforgeryToken, DisableAntiforgery).
- Confirm tokens are unpredictable, bound to the session and rejected when missing.
- Add a Fetch Metadata or exact-match
Origin check as a second layer.
- Set
SameSite, Secure and HttpOnly explicitly on session cookies.
- Require
application/json plus a custom header on cookie-authenticated APIs, and keep the CORS allowlist exact.
- Ask for the current password or a fresh second factor before email, password, MFA and payout changes.
- Retest with a cross-origin page and two test accounts after every change.
References