Testing CSRF Defences With a Cross-Origin Form

Anti-forgery protection is easy to implement and easy to leave with a hole, and the hole is always on a route nobody tested. This page is part of the mitigating CSRF attacks in modern SPAs guide and covers how to prove the protection actually works — starting with the simplest possible attacker.

The Test That Matters Is the Negative One

Tests written alongside a feature assert that a valid request succeeds. The assertion that matters here is the opposite: a request that carries the session but omits the token must fail.

The two requests a CSRF test must compare The positive case sends the session cookie and the anti-forgery token and must succeed; the negative case sends only the cookie and must be rejected with 403. Positive cookie + token header must return 200 Negative cookie only, no token must return 403
Only the right-hand case tells you anything. A suite full of positive tests passes perfectly against an application with no protection at all.

The Manual Test: A Static Form on Another Origin

Before automating anything, reproduce the attack by hand. Save an HTML file, serve it from a different origin — a local server on another port counts as cross-origin — open it in a browser where you are logged in, and submit.

<!-- attacker.html — served from http://localhost:9999, not your app's origin -->
<form method="POST" action="https://app.example.com/api/profile">
  <input name="displayName" value="pwned">
  <button>Click me</button>
</form>
<script>document.forms[0].submit();</script>

If the request succeeds, the endpoint is exposed. If it fails, note why it failed — a rejection because the token was missing is protection; a rejection because the body could not be parsed is luck, and a different content type will get through.

Repeat with the content types a plain form can send without triggering a preflight: application/x-www-form-urlencoded, multipart/form-data and text/plain. An API that assumes JSON is safe because a cross-origin fetch would be preflighted is relying on an assumption a form does not respect, and a lenient body parser will happily accept text/plain containing JSON.

Automating It Across Every Route

The manual test proves the mechanism; the automated one proves the coverage. Enumerate every state-changing route from your router and assert the negative case for each.

// Walk the router rather than maintaining a list by hand.
for (const route of statefulRoutes(app)) {
  it(`${route.method} ${route.path} rejects a request without the anti-forgery token`, async () => {
    const res = await request(app)[route.method.toLowerCase()](route.path)
      .set("Cookie", sessionCookie)      // authenticated…
      .send(route.sampleBody);           // …but no token header
    expect(res.status).toBe(403);
  });
}

Generating the list from the router is the important part. A hand-maintained list of routes to test is a list that stops being complete the first time someone adds an endpoint in a hurry, and the endpoint added in a hurry is exactly the one that will be missing its protection.

The Routes People Forget

Endpoints commonly left without anti-forgery protection Login and logout, token refresh, file uploads posted as forms, GET routes that change state, and routes registered before the middleware are the five most commonly unprotected. Login and logout — login CSRF and forced sign-out are real attacks Token refresh — authenticates with a cookie, changes state File uploads posted by a real form element, bypassing the interceptor Any GET that changes state — no header is ever sent on a navigation Routes mounted before the middleware — present in the file, never executed
The last row is the one that survives code review: the middleware is right there in the source, it simply runs after the handler it was meant to protect.

Test the ordering case explicitly by asserting that an unauthenticated request to each route returns 401. If a route answers 200 to an anonymous caller, no authentication middleware ran for it — and if authentication did not run, neither did anything else you mounted alongside it.

Testing the Double-Submit Variant Properly

If you use the double-submit pattern, the negative test above is not enough. The pattern’s weakness is an attacker who can write cookies for your domain, so the test has to reflect that.

Simulate it by setting both the cookie and the header to the same attacker-chosen value and asserting the request is rejected. With a plain double-submit token that request succeeds, which is the vulnerability; with a token bound to the session by a server-side signature it fails, which is the fix described in implementing double-submit CSRF tokens in React.

Also assert that a token from one user’s session is rejected on another user’s session. That catches an implementation that verifies the token’s shape or signature without binding it to the presenting session — a subtle bug that passes every other test.

Content Types Worth Trying

Content types a cross-origin form can send without a preflight URL-encoded, multipart and plain text bodies can all be sent by a plain HTML form with no preflight, while a JSON content type triggers one — which is why a lenient parser accepting text as JSON is a hole. application/x-www-form-urlencoded no preflight — the classic form post multipart/form-data no preflight — file upload endpoints text/plain no preflight — parsed as JSON by lenient servers
Test all three against every endpoint that accepts a body. The third is the one that defeats "our API only accepts JSON, so it is safe".

Reject unexpected content types explicitly rather than relying on the parser to fail. An endpoint that returns 415 for anything but application/json removes an entire attack path, and it does so in a way that is obvious in the code rather than dependent on parser configuration you may not control.

Keeping the Tests Meaningful

Run the suite against a build with production-like configuration, including the real cookie attributes. A test environment where the session cookie is SameSite=None or where the middleware is disabled for convenience proves nothing about what you ship.

Add the cross-origin form to your manual release checklist for the handful of most sensitive endpoints — changing a password, adding a payment method, inviting a user. Automated tests confirm the mechanism; the browser confirms that the mechanism is actually wired into the path a real attacker would use.

Finally, re-run everything after any change to the middleware stack, the router, or the framework version. This protection is not fragile in principle and is very fragile to ordering, and ordering changes arrive with upgrades far more often than with deliberate edits.

Building the Test Into Your Pipeline

A test that runs once during a security review protects you for a week. The value comes from running it on every change, which means it has to be fast and it has to be maintained by the team rather than by a security function.

Put the route-walking negative test in the same suite as your other integration tests, so it runs on every pull request and fails the build rather than producing a report someone reads later. Keep its runtime proportionate: a request per state-changing route is a few seconds even for a large application, because none of them get past the middleware.

Add a second job that runs the browser-based cross-origin form against a deployed staging environment after each release. That one is slower and covers what an in-process test cannot — the real cookie attributes, the real proxy layer, the real browser behaviour — so nightly or per-release is the right cadence rather than per-commit.

Make the failure message useful. “POST /api/profile accepted a request with no anti-forgery token” tells the next engineer exactly what happened; “expected 403, got 200” sends them reading the test to work out what it was checking. The people who hit this failure are usually not the people who wrote the test.

Finally, treat a new route with no coverage as a build failure rather than a gap. Because the list is generated from the router, an endpoint added without protection appears as a failing case automatically — which is the entire reason to generate the list from the router rather than maintain one by hand in a file nobody opens.

Frequently Asked Questions

Does a JSON API need this test?

If it authenticates with a cookie, yes. The belief that JSON is safe rests on a cross-origin fetch being preflighted — but a plain HTML form can post text/plain or application/x-www-form-urlencoded with no preflight at all, and many body parsers will accept JSON sent under those types. Test with each of them rather than assuming the content type protects you.

Should the test assert a specific status code?

Assert the request was rejected and that no state changed — the status code matters less than the outcome. A test that only checks for 403 can pass while an endpoint returns 403 for an unrelated reason, so where practical also assert that the resource is unchanged afterwards. That second assertion is what catches a handler that rejects the response but has already written to the database.

How do I test routes that require a complex request body?

Use the same body your positive test uses and simply omit the token. The point is not to exercise the handler’s logic but to confirm the request never reaches it, so a body that would otherwise be valid is exactly right — and it removes any doubt about whether the rejection came from the protection or from validation.

Is this test needed if every route requires a bearer token?

Not for routes that accept only a header credential, since the browser will not attach one cross-origin. It is needed the moment any route also accepts a cookie — including a refresh endpoint or a legacy path kept for compatibility. Generating the route list from the router is what surfaces those, because they are rarely the routes anyone thinks to check.

How often should the manual browser test be run?

Before each release for a small set of high-value endpoints, and whenever the middleware stack or framework version changes. It takes a couple of minutes with a saved HTML file, and it verifies the one thing an in-process test cannot: that a real browser, with real cookies and real attributes, behaves the way your test client assumed.