Skip to content
Cross-Site Request Forgery

Cross-Site Request Forgery, Explained: From a Hidden Form to an Account Takeover

A signed-in browser attaches its session cookie to requests that another site starts. Here is how cross-site request forgery turns that into actions the user never chose, why SameSite cookies alone do not settle it, and how tokens and origin checks close the gap.

Part 1 of 2From the Cross-Site Request Forgery series

At a glance

CWE-352
Interpreter
The victim's browser, which attaches ambient credentials to cross-site requests
Required condition
A state-changing request is authenticated only by cookies the browser sends automatically and carries no value an attacker cannot predict.
Potential impact
Actions performed as the victim: email or password changes, purchases, transfers, or role and settings changes.
Primary defense
Framework anti-CSRF tokens validated on every unsafe request, backed by Fetch Metadata or Origin checks and explicit SameSite cookies.

Safe practice: use benign proof only, in a disposable local lab or on a system you are explicitly authorized to test.

On this page

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

  1. List every state-changing endpoint, including login and logout, and confirm none runs on GET.
  2. Enable the framework's CSRF protection globally and review each exemption (csrf_exempt, IgnoreAntiforgeryToken, DisableAntiforgery).
  3. Confirm tokens are unpredictable, bound to the session and rejected when missing.
  4. Add a Fetch Metadata or exact-match Origin check as a second layer.
  5. Set SameSite, Secure and HttpOnly explicitly on session cookies.
  6. Require application/json plus a custom header on cookie-authenticated APIs, and keep the CORS allowlist exact.
  7. Ask for the current password or a fresh second factor before email, password, MFA and payout changes.
  8. Retest with a cross-origin page and two test accounts after every change.

References

Module: Cross Site Request Forgery. How it probes: Passive: no request is forged. After the crawl, it walks the forms recorded for each target: those the headless browser extracted from rendered pages, including frames, shadow DOM and the synthetic forms built for script-driven inputs, plus POST endpoints found in scripts and API definitions. Every form submitted with POST is checked for a hidden field whose name exactly matches a common anti-CSRF token name such as csrf, _csrf, csrf_token, csrfmiddlewaretoken, authenticity_token, __RequestVerificationToken, _token or xsrf, ignoring case. How it confirms: There is no active confirmation. Each POST form without such a field is reported at medium severity as a possible finding, with the form's action URL as evidence and the target's request and response attached. Tokens sent in a custom header or a differently named field, SameSite cookies and origin checks are invisible to the check, so a finding calls for the manual cross-origin replay described below. A finding moves from Possible to Confirmed only after this check. Reported as CWE-352 · ATT&CK T1190 · SARIF 2.1.0.
How SelfSec proves this one
Module
Cross Site Request Forgery
How it probes
Passive: no request is forged. After the crawl, it walks the forms recorded for each target: those the headless browser extracted from rendered pages, including frames, shadow DOM and the synthetic forms built for script-driven inputs, plus POST endpoints found in scripts and API definitions. Every form submitted with POST is checked for a hidden field whose name exactly matches a common anti-CSRF token name such as csrf, _csrf, csrf_token, csrfmiddlewaretoken, authenticity_token, __RequestVerificationToken, _token or xsrf, ignoring case.
How it confirms
There is no active confirmation. Each POST form without such a field is reported at medium severity as a possible finding, with the form's action URL as evidence and the target's request and response attached. Tokens sent in a custom header or a differently named field, SameSite cookies and origin checks are invisible to the check, so a finding calls for the manual cross-origin replay described below.
Reported as CWE-352 · ATT&CK T1190 · SARIF 2.1.0

A reflected response alone is not enough to promote a Cross-Site Request Forgery finding. The classical engine has to confirm the behavior before it reaches the report. See the detection engine

SelfSec scanner

Find Cross-Site Request Forgery in your own app

Reading about the class is one thing. SelfSec tests for it against an application you are authorized to assess, and only calls it real when independent evidence agrees.

Find it Confirmed, not guessed 21 vulnerability modules, with the classical engine owning the verdict. Prove it Evidence you can re-read Every finding keeps the raw request, the response and the baseline it was measured against.
Join the launch list