# CSRF testing: how pentesters check tokens, SameSite and login CSRF

Source: https://penetrationtestservices.com/guides/csrf-testing/
Publisher: Excession Labs (penetrationtestservices.com)
Last updated: 2026-11-09

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](/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:

1. The action changes state (change email, transfer funds, add a user).
2. The app identifies the user only by cookies, with no extra proof.
3. 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-site `fetch()` or `` does not qualify. A cross-site form `POST` does not qualify either.
- `None`: send it on all requests. It needs the `Secure` attribute.

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:

```http
Set-Cookie: session=; 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.

1. Inventory. List every request that changes state: forms, `fetch` calls, GraphQL mutations. Include the ones behind settings pages.
2. Check for a token. Look for a random value in a hidden field, header or cookie.
3. Remove the token. Replay the request with the token missing. Replay with an empty value.
4. Swap the token. Use a token from another test account. Use an old token from the same account.
5. Change the method. If `POST` is protected, try `GET` with the same parameters.
6. Check `Origin` and `Referer` handling. See if a request with a foreign `Origin` is accepted.
7. Check cookie attributes. Read `SameSite`, `Secure` and `HttpOnly` on the session cookie.
8. 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:
> 
> ```html
> 
>   
> 
> ```
> 
> The browser sends:
> 
> ```http
> POST /settings/email HTTP/1.1
> Host: app.harbor-notes.example.com
> Origin: https://lab.example.net
> Cookie: 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](/guides/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=Lax` or `Strict` on the session cookie, set explicitly.
- Verify `Origin` (and `Referer` as a fallback) on state-changing requests. Reject foreign values.
- Use only `POST`, `PUT`, `PATCH` or `DELETE` for changes. Never change state on `GET`.
- 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.

```text
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](/guides/business-logic-vulnerabilities/) and the [OWASP Top 10 2025 guide](/guides/owasp-top-10-2025/).

## 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=Strict` or `Lax` cookie may not reach the app on a cross-site `POST`. 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 `Origin` check 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](https://wstg.owasp.org/v4.2/4-Web_Application_Security_Testing/06-Session_Management_Testing/05-Testing_for_Cross_Site_Request_Forgery/)
- [OWASP Cross-Site Request Forgery Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html)
- [MDN: Set-Cookie header (SameSite attribute)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie)
- [Chromium: SameSite Updates](https://www.chromium.org/updates/same-site/)
