Web application testing
CSRF testing: how pentesters check tokens, SameSite and login CSRF
How to test CSRF protection: an 8-step method, a test-case table for tokens, Origin and SameSite, JSON endpoints, login CSRF, and proof in a real browser.
Short answer
CSRF (cross-site request forgery) makes a signed-in user's browser send an unwanted request to your app. Testers check that every state-changing request needs a valid anti-CSRF token or an equivalent check, that session cookies set SameSite, and that login is protected too. They prove it in a real browser, because browser rules decide whether the cookie is sent.
CSRF stands for cross-site request forgery. It makes a signed-in user's browser send a request the user did not intend. The OWASP WSTG describes it as "an attack that forces an end user to execute unintended actions on a web application in which they are currently authenticated."
CSRF testing checks that every state-changing request needs proof that it came from your own pages. Testers check tokens, SameSite cookies, JSON endpoints and login CSRF, and prove each result in a real browser. These checks are part of web application penetration testing.
How does CSRF work?#
CSRF works because browsers attach cookies to requests automatically. If a page on another site can make the browser send a request to your app, the cookie goes along. The app then acts as the signed-in user.
Three conditions must hold:
- The action changes state (change email, transfer funds, add a user).
- The app identifies the user only by cookies, with no extra proof.
- The attacker can predict every parameter in the request.
An anti-CSRF token breaks condition 3. A SameSite cookie breaks condition 2 in many cases.
What do SameSite cookies change?#
SameSite tells the browser when to send a cookie on cross-site requests. MDN explains the three values:
Strict: send the cookie only for requests from the same site.Lax: also send it on cross-site top-level navigations that use a safe method. MDN says this "essentially means that the request causes the URL shown in the browser's address bar to change." A cross-sitefetch()or<iframe>does not qualify. A cross-site formPOSTdoes not qualify either.None: send it on all requests. It needs theSecureattribute.
If the attribute is missing, behavior depends on the browser. MDN says "Some browsers use Lax as the default value if SameSite is not specified." The Chromium project documents its move to Lax-by-default. MDN adds that this default is more permissive than an explicit Lax: cookies are also sent on cross-site POST requests made within two minutes of the cookie being set.
Set the attribute yourself and do not rely on defaults:
Set-Cookie: session=<opaque-id>; Path=/; Secure; HttpOnly; SameSite=Lax
"Same site" is not "same origin". A sibling subdomain such as blog.example.com is same-site with app.example.com, so a flaw on any sibling can send same-site requests with cookies.
How do testers test for CSRF?#
Testers list the state-changing requests, then check each for a token or equivalent. They follow this order.
- Inventory. List every request that changes state: forms,
fetchcalls, GraphQL mutations. Include the ones behind settings pages. - Check for a token. Look for a random value in a hidden field, header or cookie.
- Remove the token. Replay the request with the token missing. Replay with an empty value.
- Swap the token. Use a token from another test account. Use an old token from the same account.
- Change the method. If
POSTis protected, tryGETwith the same parameters. - Check
OriginandRefererhandling. See if a request with a foreignOriginis accepted. - Check cookie attributes. Read
SameSite,SecureandHttpOnlyon the session cookie. - Prove it in a browser (next section).
Test cases#
| Case | Request change | Safe result | Finding if |
|---|---|---|---|
| Token missing | Remove the CSRF field | 403 | Action succeeds |
| Token empty | Send empty value | 403 | Action succeeds |
| Token from account B | Use B's token for A's session | 403 | Action succeeds |
| Token reuse after logout | Replay an old token | 403 | Action succeeds |
| Method switch | GET instead of POST |
405 or 403 | State changes |
Foreign Origin |
Origin: https://evil.example.net |
403 | Action succeeds |
| Content type | text/plain body to JSON API |
415 or 403 | Action succeeds |
| Cookie flags | Read Set-Cookie |
SameSite set |
Missing or None |
Why does browser-observed proof matter?#
A browser decides which cookies go with a request, while curl sends whatever you give it. A "missing token" in curl therefore does not prove CSRF. The attack works only if a real browser, on a real cross-site page, sends the session cookie.
For this reason we label CSRF evidence as Browser-observed. We load a test page from a different site in Chromium, signed in as a test account, check whether the state changed, and read the change back. A "success" response alone is not proof.
Illustrative CSRF proof
This is a demonstration. No real customer system was tested. Hosts are fictional example.com hosts.
A test account is signed in to app.harbor-notes.example.com. In the same Chromium profile, the tester opens a page on lab.example.net (a test host the tester controls) with a form that posts an email change:
<form method="POST" action="https://app.harbor-notes.example.com/settings/email">
<input name="email" value="tester+csrf@example.com">
</form>
The browser sends:
POST /settings/email HTTP/1.1
Host: app.harbor-notes.example.com
Origin: https://lab.example.net
Cookie: session=<test-session>
Content-Type: application/x-www-form-urlencoded
email=tester%2Bcsrf%40example.com
If the server returns 302 and the account page now shows the new email, CSRF is proven. If the browser omits the Cookie header (for example under SameSite=Lax on a cross-site POST), the request fails with 401, and the control held.
What about JSON endpoints?#
JSON APIs that use cookies still need CSRF protection. A cross-site form cannot send application/json, but it can send text/plain with a body that looks like JSON. If the server parses the body without checking the content type, the request goes through.
Test by sending the JSON body with Content-Type: text/plain and application/x-www-form-urlencoded. A safe server returns 415 Unsupported Media Type or checks a token. APIs that need an Authorization: Bearer header are not open to classic CSRF, because the browser does not add that header on its own.
What is login CSRF?#
Login CSRF forces a victim to sign in as the attacker. The victim then uses the app without knowing it, and stores data in the attacker's account. It needs no existing session.
To test it, check whether the login form has a token that is bound to a pre-login session. Submit the login form from a cross-site page with a test account's credentials, in a browser. If the browser ends up signed in as that test account, login CSRF is possible. Also check OAuth: a missing state parameter allows the same attack. See the authentication testing checklist.
How do teams fix CSRF?#
Use several defenses together, because no single one covers every case.
- Synchronizer token or signed double-submit token on every state-changing request, including login. Bind it to the session.
SameSite=LaxorStricton the session cookie, set explicitly.- Verify
Origin(andRefereras a fallback) on state-changing requests. Reject foreign values. - Use only
POST,PUT,PATCHorDELETEfor changes. Never change state onGET. - Require a custom header or a JSON content type on APIs, and reject others.
- Re-authenticate for sensitive actions such as email, password and payout changes.
on every non-GET request:
if request.origin not in allowed_origins: reject 403
if request.header["X-CSRF-Token"] != session.csrf_token: reject 403
After the fix, ask for a retest in a browser that repeats the same cross-site page and confirms the state does not change. Related reading: the business logic guide and the OWASP Top 10 2025 guide.
What are common false positives in CSRF testing?#
A missing token is not always a vulnerability. Testers check these cases before they report.
- The endpoint needs a bearer header. The browser does not add it for another site, so classic CSRF does not apply.
- The action is harmless. Changing a UI theme has little impact, and severity depends on what the request changes.
- The cookie is not sent. A
SameSite=StrictorLaxcookie may not reach the app on a cross-sitePOST. Prove it in the browser. - A second factor guards the action. If the request needs a fresh password or code, a forged request fails.
- An
Origincheck blocks it. The server rejects foreign origins even with no token.
The reverse also happens: a request that fails in a script may work in a browser because of cookies, redirects or Origin rules. So prove CSRF in a real browser and say so in the report. Report only what the browser showed, and put the rest in an open questions section.
Questions
Does SameSite=Lax make CSRF tokens unnecessary?
No. SameSite is a strong extra layer, but it depends on the browser, on cookie settings, and on the definition of same-site. Subdomains, GET requests that change state, and the two-minute Lax-by-default window all leave gaps. Keep tokens.
Are JSON APIs safe from CSRF?
Not by default. If the API accepts cookies and also accepts form-encoded or text/plain bodies, a cross-site form can send the request. If it needs a bearer token in a header, the browser does not add it for another site.
What is login CSRF?
Login CSRF forces a victim's browser to sign in to an account the attacker controls. The victim then saves data into the attacker's account. It needs no existing session, so a missing token on the login form is enough.
Why do testers need a real browser?
Because a raw HTTP request skips browser rules. The browser decides which cookies to send, based on SameSite, origin and request type. A request that works in a script may fail in Chrome, and the reverse can also happen.
Sources
- OWASP WSTG-SESS-05: Testing for Cross Site Request Forgery (checked 9 November 2026)
- OWASP Cross-Site Request Forgery Prevention Cheat Sheet (checked 9 November 2026)
- MDN: Set-Cookie header (SameSite attribute) (checked 9 November 2026)
- Chromium: SameSite Updates (checked 9 November 2026)