A missing token is not proof: verifying a CSRF vulnerability
A form with no anti-CSRF token, or a request that still goes through after you strip the token in Burp, is where most CSRF reports begin and end. But a missing or unvalidated token is a precondition, not an exploit: it proves the server skipped a check, not that an attacker can force the action from another site. Whether the attack actually works is decided in the victim's browser, by the three conditions CSRF needs and by the SameSite cookie default Chrome has shipped since 2021. A practitioner's guide to telling an unchecked token apart from a real cross-site forgery, proving it with a cross-site PoC in a live browser, why GET-based state changes still bite, and the token and SameSite defenses that hold.
By The Xalgorix Team
Two observations end most cross-site request forgery reports before they have started. The first: “this form has no anti-CSRF token.” The second, one step braver: “I deleted the token in Burp Repeater and the request still went through.” Both feel like findings, and both are preconditions at best. A server that does not validate a token has skipped a check — but CSRF is not “the server skipped a check.” It is that an attacker can make a victim's browser send a state-changing request from another site, carrying the victim's session, without the victim's intent. Whether that is possible is not decided in Repeater, which sends whatever cookies you tell it to. It is decided in a real browser, by whether three conditions all hold and whether the browser will even attach the session cookie to a cross-site request — something that changed for the whole web when Chrome made SameSite=Lax the default in 2021.
Why a missing token is not a vulnerability
The weakness has a precise name: CWE-352, Cross-Site Request Forgery (CSRF). The definition that matters is PortSwigger's: CSRF “allows an attacker to induce users to perform actions that they do not intend to perform,” partly circumventing the same origin policy. The absence of a token is one reason an application might be forgeable. It is not the thing itself. Strip the token and three very different situations can follow, and only one is a reportable CSRF.
The three conditions, all of them
PortSwigger lists three conditions that must all be in place for CSRF to be possible. A verification is really just confirming each one, in order:
- A relevant action. “There is an action within the application that the attacker has a reason to induce” — changing an email, promoting an account, transferring funds. A read-only request is not worth forging.
- Cookie-based session handling. “The application relies solely on session cookies to identify the user.” This is the condition most missed reports fail. If the endpoint also requires a bearer token in an
Authorizationheader, or any value a cross-site attacker cannot set, the browser will not supply it for them — so there is nothing to forge. - No unpredictable request parameters. “The requests that perform the action do not contain any parameters whose values the attacker cannot determine or guess.” A valid anti-CSRF token is exactly such a parameter — which is why its absence is a precondition. But so is any per-user secret the request happens to need.
The signal that lies: Repeater is not a browser
Deleting the token in Repeater and watching the request succeed proves one thing precisely: the server does not validate the token. That is useful — it establishes condition three — but it is not an exploit, because Repeater attaches whatever cookies you configured, from a tool running under your control, not a victim's browser making a cross-siterequest. The question that decides exploitability is the one Repeater cannot answer: when a page on attacker.example triggers this request, will the victim's browser attach their session cookie to it at all?
Since 2021, by PortSwigger's account, “Chrome enforces Lax SameSite restrictions by default,” and other major browsers have moved the same way. Under Lax, the browser withholds the cookie from the kinds of cross-site requests a classic CSRF relies on — most importantly a cross-site form POST. So a request that sailed through Repeater can simply never fire with credentials from another origin. That is not the server being safe; it is the browser refusing to be the attacker's courier. Either way, the report is only real if you can show the request completing from a cross-site context with the victim's cookie attached.
SameSite, GET, and the gaps that keep CSRF alive
The default did not kill CSRF; it narrowed it. The gaps are where real findings now live, and knowing them tells you what to test:
- GET-based state changes.
Laxstill sends cookies on top-level navigations that use a safe method — so if a state-changing action is reachable by GET, a link the victim follows, awindow.locationredirect, or an auto-submitting top-level GET form will carry the session. Note the boundary precisely: “top-level navigation” is the whole page going to the URL, not a cross-site subresource — an<img>,fetch, or hidden iframe to the endpoint does not carry aLaxcookie (that only works if the cookie isNone). OWASP is blunt:SameSite=Lax“provides little protection if any state-changing operation is reachable via GET.” Finding one is the single highest-value CSRF check on a Lax-by-default web. - Chrome's two-minute window. Chrome exempts cookies set without a SameSite attribute less than two minutes ago, sending them even on top-level cross-site
POST(the “Lax + POST” intervention). A login that sets a bare session cookie can be CSRF-able for about 120 seconds after sign-in. It is a Chrome-specific stopgap that may be removed, so treat it as a narrowing window, not a durable primitive. SameSite=Nonecookies. A site that opts back out — often for third-party embedding or SSO — sends its session cookie cross-site exactly as before 2021. If the session cookie isNone; Secure, SameSite is not protecting it.- Cross-site is not cross-origin. SameSite keys on the registrable domain, so
a.target.comandb.target.comare the samesite. An injection or an open gadget on any sibling subdomain can issue the request from within the SameSite boundary.
Proving it: a cross-site PoC in a live browser
The proof is a page hosted on an origin you control that, when opened in a browser already logged in to the target, completes the action against that account with no further interaction. For a form POST that still rides (a None cookie, the two-minute window, or a same-site gadget), the canonical PoC is an auto-submitting form:
<!-- Hosted on https://attacker.example/poc.html --> <form action="https://target.example/account/email" method="POST"> <input type="hidden" name="email" value="attacker@evil.example"> </form> <script>document.forms[0].submit();</script> Open this while logged in to target.example in another tab. PROOF = the victim account's email is now attacker@evil.example, changed by a request the victim never issued, with their cookie attached by the browser -- not by you.
When the state change is reachable by GET, the PoC drops the hidden inputs — but it must still be a top-level navigation, because a Lax cookie rides a navigation, not a subresource. Send the whole page to the URL:
<!-- GET-based state change: this must be a TOP-LEVEL navigation.
A Lax cookie rides a navigation, not a subresource, so an
<img>/fetch to this URL would NOT carry it (only a None cookie would). -->
<script>location = "https://target.example/account/promote?role=admin";</script>
<!-- Or, to avoid script, an auto-submitting top-level GET form: -->
<form action="https://target.example/account/promote" method="GET">
<input type="hidden" name="role" value="admin">
</form>
<script>document.forms[0].submit();</script>What turns this from a demo into evidence is the differential: the action completes when the victim is logged in and the request originates cross-site, and it fails when they are logged out, or when the (now-fixed) endpoint demands a token your page cannot supply. Confirm the effect on the victim's account, not just a 200 — the same discipline that separates a real access-control break from a reflected status code. This is also where honesty about automated tooling matters, Xalgorix included: a scanner reporting “no anti-CSRF token” has found a candidate, not a vulnerability. The thing worth shipping is the re-exploit that drives a real browser to complete the action cross-site, because a developer can watch it happen and a triager can trust it.
The 2021 default that quietly retired half the old reports
It is worth dwelling on why so many pre-2021 CSRF write-ups stopped reproducing. The shift was not a patch to any one app; it was browsers changing the default. When Chrome began treating cookies with no explicit SameSite attribute as Lax, every application that had been implicitly relying on the browser to send its session cookie cross-site — which was all of them — gained a layer of protection it never wrote. A form-POST CSRF that was trivially exploitable in 2020 may simply not fire today.
The honest reading cuts both ways. It means a missing token is weaker evidence than it used to be, so the burden of a working cross-site proof is higher. And it means the defense is partly outside the application's control: it depends on the user's browser and version, it has documented gaps, and — as OWASP puts it — SameSite is “useful as a defense-in-depth control but it does not replace a proper CSRF defense in most deployments.” Verifying a CSRF today means proving the request still rides through one of the gaps; fixing it means not depending on the browser default at all.
Signals that mislead — in both directions
- “No token” on a bearer-auth API. If the session is an
Authorizationheader the client sets in script, there is no ambient credential to forge. Report it as CSRF only after confirming a cookie actually authenticates the request. - “It worked in Repeater.” A same-origin tool proves the server does not validate the token, not that a browser will send the cookie cross-site. Move the test into a real cross-site page before you believe it.
- Real token flaws worth proving. A token the server checks only when present, or only on
POSTbut not GET, or that is not tied to the user's session, or that is merely duplicated in a cookie the attacker can set — each is a genuine bypass. PortSwigger documents all of them; prove the bypass, not just the gap. - Referer-only validation. Checking the
Refererheader is, per PortSwigger, “generally less effective than CSRF token validation” — it often fails open when the header is absent, which an attacker can arrange.
The fix that holds
Do not lean on the browser default. The primary defense remains the one OWASP calls “one of the most popular and recommended methods”: the synchronizer token — an unpredictable, per-session value the server issues and then requires on every state-changing request, and which, critically, is not transmitted in a cookie. Where a stateless pattern is needed, OWASP's recommended form is the signed (HMAC) double-submit cookie, bound to a session-dependent value and compared in constant time — not the naive double-submit, which it marks as bypassable by anyone who can write a cookie on the domain.
// Synchronizer token: reject unless a valid, session-bound token is present.
function handleStateChange(req, session) {
const sent = req.body.csrfToken; // from the form, NOT a cookie
if (!sent || !timingSafeEqual(sent, session.csrfToken)) {
return reject(403); // missing OR wrong both fail closed
}
// ...perform the action...
}
// JSON API: require a custom header browsers can't set cross-site without
// a CORS preflight, and keep CORS strict (no wildcard subdomains).
if (!req.headers.get("X-Requested-With")) return reject(403);
// And regardless of the above: never change state on GET, and send the
// session cookie as SameSite=Lax (or Strict) Secure HttpOnly as defense in depth.The common thread is that none of these depends on when or where the request originates being judged by the browser alone. A synchronizer token the attacker cannot read, a header they cannot set cross-site, and state changes kept off GET together remove every one of the three conditions an attacker needs — while SameSite sits behind them as a backstop, not the wall.
Ready to see it prove a bug?
Start scanning on Cloud →