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 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
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
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.
Related
- Mitigating CSRF attacks in modern SPAs — the patterns these tests verify.
- Implementing double-submit CSRF tokens in React — why the token must be bound to the session.
- Configuring secure cookie flags in production — the
SameSitelayer these tests should run against.