Triaging CSRF Reports After SameSite by Default
How to triage CSRF reports now that browsers default to SameSite=Lax: what still works, JSON preflight nuances, login CSRF, false positives, and severity.
Chrome began treating cookies without a SameSite attribute as Lax in 2020, finished the rollout in 2021, and Firefox and Safari arrived at similar outcomes by different routes (Total Cookie Protection and ITP's third-party cookie blocking respectively). The practical effect is that the classic CSRF PoC, an auto-submitting HTML form on an attacker page that posts to a victim's authenticated endpoint, no longer sends the session cookie in a default configuration. Most CSRF reports submitted today are describing an attack the browser already prevents.
That does not make the class dead, and triage that closes every CSRF report with "SameSite defaults protect this" will be wrong several times a year. The exceptions are specific, testable, and worth knowing precisely.
Establish the cookie's actual SameSite value first
Do not reason from the default. Read the Set-Cookie header on the session cookie and record what it says.
HTTP/1.1 200 OK
Set-Cookie: session=eyJ...; Path=/; HttpOnly; Secure; SameSite=None
SameSite=None; Secure is an explicit opt-out of the protection, and it exists for legitimate reasons: embedded widgets, iframed apps, third-party SSO handshakes, checkout flows split across domains, ad and analytics integrations. Whenever the product supports embedding, the session cookie is often None, and classic cross-site CSRF works exactly as it did in 2015. A report against an app with SameSite=None sessions and no token validation is a real finding, full stop.
Absent SameSite entirely means Lax in current browsers, which is not the same thing as None, and reports that treat a missing attribute as a vulnerability in itself are usually Informative. Note the exception: embedded WebViews, older mobile browser engines, and some corporate-managed browsers do not enforce the default, so an app whose primary client is an in-app WebView deserves a closer look.
What still works under Lax
Four categories, in rough order of how often they show up.
State-changing GET requests. Lax cookies are sent on top-level cross-site navigations, and that includes GET. If any endpoint changes state on GET (/account/delete?confirm=1, /api/keys/revoke/8814, /settings/2fa/disable), SameSite=Lax provides no protection at all. An attacker page with an <img> tag will not trigger it because that is a subresource load rather than a top-level navigation, but a window.open or a plain link the victim clicks will. This is the single most common live CSRF in modern applications and it is regularly missed because reviewers stop at "we set SameSite".
The Lax-plus-POST window. Chrome carved out a compatibility exception: cookies created less than two minutes ago are still sent on top-level cross-site POST requests. That turns "CSRF is dead" into "CSRF works for two minutes after login." An attacker who controls the flow can arrange to be inside that window, typically by chaining login CSRF or by targeting a login-then-visit sequence in a phishing page. Chrome has signaled intent to remove this heuristic, so check current behavior in the browser version you test with and record the version in your verdict. Firefox never implemented the exception.
Same-site but cross-origin positions. SameSite is scoped to the registrable domain (eTLD+1), not the origin. Anything on *.example.com is same-site with app.example.com, so a subdomain takeover, an XSS on a marketing subdomain, or an insecure http:// subdomain vulnerable to network interception all give an attacker a position where SameSite contributes nothing. When a report chains one of those into a CSRF, rate the chain. Our subdomain takeover guide covers how to verify the first half, and the XSS guide the other.
Ambient auth that is not a cookie. HTTP Basic credentials, NTLM and Kerberos on intranet-facing applications, and TLS client certificates are all attached by the browser or OS without regard to SameSite. Internal tools and appliances are where this bites.
Separately, verify that the token mechanism, if present, actually validates. Common broken implementations: the token is checked only when the parameter exists (omit it entirely and the check is skipped), the token is not bound to the session (any valid token from any user is accepted), the token is echoed from a request header the attacker can influence, or double-submit is implemented against a cookie the attacker can set from a sibling subdomain.
JSON content types and preflight
A large share of "the API has no CSRF token" reports are not exploitable, and the reason is the same-origin policy rather than any application control.
An HTML form can only produce three content types: application/x-www-form-urlencoded, multipart/form-data, and text/plain. It cannot send application/json. A fetch or XMLHttpRequest that sets Content-Type: application/json is not a simple request, so the browser sends a CORS preflight:
OPTIONS /api/v1/account/email HTTP/1.1
Host: app.example.com
Origin: https://evil.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type
If the server does not respond with matching Access-Control-Allow-Origin and Access-Control-Allow-Headers, the real request is never sent. No CSRF.
The exceptions are worth testing every time, because they are common:
- The server parses non-JSON content types as JSON. Express with a loosely configured
body-parser, Spring with a permissive message converter, and several PHP frameworks that read the raw body regardless of the header will happily accept a form-encoded ortext/plainbody containing JSON. Resend the request withContent-Type: text/plainand a JSON body. If it succeeds, a form-based PoC is possible. - The
text/plainpadding trick. A form's body isname=value, so you send a field named{"email":"[email protected]","x":"with value"}, producing a body that parses as valid JSON. - The server ignores
Content-Typecompletely. Some routers dispatch on the path and let the framework sniff the body. - Permissive CORS. If the response reflects arbitrary
Originvalues withAccess-Control-Allow-Credentials: true, you no longer have a CSRF report. You have a cross-origin read-and-write finding, which is more severe, and it should be retitled and rated accordingly.
Similarly, an endpoint that requires a custom header (X-Requested-With, X-CSRF-Token, an API version header) is protected by the preflight requirement, provided CORS is not permissive. Reports that flag a missing token on such an endpoint are usually Informative.
Login and logout CSRF
Logout CSRF is an annoyance. On its own it is Informative on nearly every program. The exception is when logging the victim out is a required step in a larger chain, for instance forcing a re-login to land inside the two-minute Lax window.
Login CSRF is more interesting than its reputation. The attacker forces the victim's browser to authenticate as the attacker, and the victim then operates inside the attacker's account without noticing. What that is worth depends entirely on what the victim does next:
- Enters a payment method, which the attacker can then use or view.
- Uploads documents or writes notes that the attacker later reads.
- Generates search or purchase history the attacker can retrieve.
- Links an OAuth identity to the attacker's account, which can hand over persistent access to the victim's third-party identity.
That last case is the one that turns login CSRF into a High. Absent a demonstrated consequence, Low to Medium is a defensible range. Ask what the reporter can show a victim losing, not just that the login fired.
Common false positives
| Reported as CSRF | Usually is |
|---|---|
| No token on an unauthenticated endpoint | Nothing to ride; no session, no CSRF |
| GET request that reads but changes nothing | Not a state change |
| Endpoint requiring a custom header, CORS locked down | Protected by preflight |
Session cookie has no SameSite attribute |
Defaults to Lax; note it, do not rate it as CSRF alone |
| PoC page loaded, request appeared in the network tab, state unchanged | The cookie was not attached, or the server rejected it |
PoC tested against localhost:3000 from localhost:8080 |
Same site (different port is same-site); not a valid cross-site test |
PoC opened via file:// |
Origin quirks; retest from a real attacker origin |
| Token present but not rotated per request | Hardening at most |
The network-tab case deserves emphasis. A researcher opens their PoC, sees the POST in devtools, sees a CORS error in the console, and reports success. For a simple request the browser does send it and the server does process it even though the response is unreadable, so the CORS error alone proves nothing in either direction. Verify at the state layer: did the email change, did the key get revoked, did the transfer post? The same "check the artifact, not the response" rule applies here as in race condition triage.
The port confusion is worth watching too. SameSite treats scheme and registrable domain, so http://localhost:8080 and http://localhost:3000 are same-site, and a PoC that works between them proves nothing about cross-site behavior. Retest from a different registrable domain, in a fresh browser profile, with a real victim session.
Severity reasoning
Rate on the action, then subtract for preconditions rather than dismissing the report because a precondition exists.
| Scenario | Rough impact |
|---|---|
Account takeover action (change email, change password without current password, disable 2FA, add an SSH or API key) with SameSite=None |
Critical to High |
| Same actions reachable via a state-changing GET | High |
| Same actions reachable only inside the two-minute Lax POST window | High to Medium |
| Same actions requiring a subdomain XSS or takeover first | Rate the chain, typically High |
| Funds transfer or irreversible destructive action, bounded | High to Medium |
| Login CSRF with a demonstrated consequence (payment method, OAuth linking) | Medium to High |
| Login CSRF, no demonstrated consequence | Low to Medium |
| Preference or cosmetic change (theme, notification read state, cart contents) | Low to Informative |
| Logout CSRF | Informative |
Two notes on writing the verdict. Preconditions belong in the text, not just in the number: "requires the session cookie to be SameSite=None, confirmed on this host" and "requires the victim to be within Chrome's two-minute post-login window, tested on Chrome 131" are the sentences that make a downgrade defensible six months later when someone re-reads the ticket. And when you close a CSRF report on browser defaults, name the browser and version you tested. Defaults change, exceptions get removed, and a report that is unexploitable today may be exploitable in a WebView the program ships next quarter. The reproduce-then-rate discipline in our triage workflow and the impact-first scoring in our severity guide both apply here, with the extra wrinkle that the mitigating control lives in the browser rather than the application.
CSRF triage has shifted from "is there a token" to "which browser behavior is carrying the defense, and does it hold for this endpoint," and that question takes more testing than most queues budget for. TRIAGERS™ runs on-demand triage teams who check the cookie attributes, the content-type handling, and the GET endpoints before assigning a verdict. If your CSRF queue could use a second pair of eyes, reach out.