Automated Tests
Without tests, your CSRF defences quietly erode. Regression tests should cover every state-changing endpoint: missing token rejected, mismatched token rejected, valid token accepted, and CORS preflight from unknown origin denied. Bake them into CI so a junior dev removing “that weird header check” gets a red build.
Test patterns + Express/Django/Rails
EXAMPLE
// 1) The bare-minimum tests every CSRF defence needs
// • Missing token → 403
// • Token mismatch → 403
// • Token valid → 2xx
// • Token reuse after rotation → 403
// • Cross-origin OPTIONS without allowlist → no Access-Control-Allow-Origin
// • Login establishes fresh CSRF token (session fixation defence)
// 2) Express + supertest + Jest/Vitest
import request from 'supertest';
import { app } from '../src/server.js';
describe('CSRF', () => {
let agent, csrf;
beforeEach(async () => {
agent = request.agent(app); // persists cookies
const r = await agent.get('/login');
csrf = parseCsrfCookie(r.headers['set-cookie']);
await agent.post('/login').send({ csrf, email: 'mara@example.com', password: 'pw' });
});
test('POST without CSRF header is rejected', async () => {
const r = await agent.post('/api/profile').send({ name: 'X' });
expect(r.status).toBe(403);
});
test('POST with mismatched header is rejected', async () => {
const r = await agent.post('/api/profile').set('X-CSRF-Token', 'mismatch').send({ name: 'X' });
expect(r.status).toBe(403);
});
test('POST with valid token succeeds', async () => {
const r = await agent.post('/api/profile').set('X-CSRF-Token', csrf).send({ name: 'X' });
expect(r.status).toBe(200);
});
test('Login rotates CSRF token (session fixation)', async () => {
const fresh = request.agent(app);
const before = parseCsrfCookie((await fresh.get('/login')).headers['set-cookie']);
await fresh.post('/login').send({ csrf: before, email: 'mara@example.com', password: 'pw' });
const after = parseCsrfCookie((await fresh.get('/me')).headers['set-cookie']);
expect(after).not.toEqual(before);
});
test('CORS preflight from unknown origin is not approved', async () => {
const r = await request(app)
.options('/api/profile')
.set('Origin', 'https://evil.example.com')
.set('Access-Control-Request-Method', 'POST');
expect(r.headers['access-control-allow-origin']).toBeUndefined();
});
});
function parseCsrfCookie(setCookieHeader) {
if (!setCookieHeader) return null;
const arr = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader];
for (const c of arr) {
const m = c.match(/csrf-token=([^;]+)/);
if (m) return decodeURIComponent(m[1]);
}
return null;
}
// 3) Pre-login CSRF
test('login form rejects request without pre-login CSRF', async () => {
const r = await request(app).post('/login').send({ email: 'mara@example.com', password: 'pw' });
expect(r.status).toBe(403);
});
// 4) Sensitive flows require recent re-auth
test('email change requires recent re-auth', async () => {
// Login old enough that recent-auth window expired
const r = await agent.post('/account/email-change')
.set('X-CSRF-Token', csrf)
.send({ newEmail: 'evil@example.com' });
expect(r.status).toBe(401);
});
// 5) SameSite assertions
test('session cookie has SameSite=Lax + HttpOnly + Secure', async () => {
const r = await request(app).get('/login');
const sc = r.headers['set-cookie'] ?? [];
const session = sc.find((c) => c.startsWith('session='));
expect(session).toMatch(/HttpOnly/);
expect(session).toMatch(/Secure/);
expect(session).toMatch(/SameSite=Lax|SameSite=Strict/);
});
// 6) Django + pytest
# tests/test_csrf.py
import pytest
from django.test import Client
from django.urls import reverse
@pytest.mark.django_db
def test_post_without_csrf_rejected(client):
response = client.post(reverse('profile_update'), {'name': 'X'})
assert response.status_code == 403
@pytest.mark.django_db
def test_post_with_csrf_succeeds(client, django_user_model):
user = django_user_model.objects.create_user('mara', password='pw')
client.login(username='mara', password='pw')
# CsrfViewMiddleware token rotation
client = Client(enforce_csrf_checks=True)
client.login(username='mara', password='pw')
client.get('/') # set csrftoken cookie
token = client.cookies['csrftoken'].value
response = client.post(reverse('profile_update'), {'name': 'X'}, HTTP_X_CSRFTOKEN=token)
assert response.status_code == 200
# 7) Rails + RSpec
require 'rails_helper'
RSpec.describe 'CSRF', type: :request do
before(:each) { ActionController::Base.allow_forgery_protection = true }
it 'rejects POST without authenticity_token' do
post '/profile/update', params: { name: 'X' }
expect(response).to have_http_status(:unprocessable_entity)
end
it 'accepts POST with valid token' do
get '/' # establishes session
post '/profile/update', params: { name: 'X', authenticity_token: form_authenticity_token }
expect(response).to have_http_status(:ok)
end
end
# 8) Playwright — end-to-end check
import { test, expect } from '@playwright/test';
test('CSRF blocks forged form submission', async ({ page, context }) => {
await page.goto('https://app.example.com/login');
await page.fill('#email', 'mara@example.com');
await page.fill('#password', 'pw');
await page.click('button[type="submit"]');
await page.waitForURL('**/dashboard');
// Open evil page in same browser context (sharing cookies)
const evil = await context.newPage();
await evil.setContent(`
<form id="f" action="https://app.example.com/api/profile" method="POST">
<input name="name" value="compromised">
</form>
<script>document.getElementById('f').submit();</script>
`);
const response = await evil.waitForResponse('**/api/profile');
expect(response.status()).toBe(403);
});
# 9) Negative tests — make sure your defences haven't disabled too much
test('CORS preflight from allowlisted origin IS approved', async () => {
const r = await request(app)
.options('/api/profile')
.set('Origin', 'https://app.example.com')
.set('Access-Control-Request-Method', 'POST');
expect(r.headers['access-control-allow-origin']).toBe('https://app.example.com');
expect(r.headers['access-control-allow-credentials']).toBe('true');
});
# 10) Snapshot tests for security headers
test('responses ship the security header set', async () => {
const r = await request(app).get('/');
expect(r.headers).toMatchObject({
'strict-transport-security': expect.stringMatching(/max-age=/),
'x-content-type-options': 'nosniff',
'referrer-policy': expect.any(String),
'permissions-policy': expect.any(String),
'content-security-policy': expect.stringContaining('default-src'),
});
});
# 11) Property-based tests
import fc from 'fast-check';
test('any random non-matching token is rejected', async () => {
await fc.assert(fc.asyncProperty(
fc.string({ minLength: 1, maxLength: 100 }),
async (token) => {
const r = await agent.post('/api/profile').set('X-CSRF-Token', token).send({ name: 'X' });
if (token === csrf) return true; // skip the rare valid hit
expect(r.status).toBe(403);
return true;
},
), { numRuns: 100 });
});
# 12) Cross-cutting: every new endpoint should be auto-tested
# • A loop over all routes asserting state-changing ones require CSRF
for (const path of allWriteRoutes) {
test(`${path} requires CSRF`, async () => {
const r = await agent.post(path).send({});
expect(r.status).toBe(403);
});
}
# 13) CI guardrails
# • Run CSRF test suite on every PR
# • Block merges if any test fails
# • Run a synthetic check in production every 5 min and alert on drift
# 14) Common bugs
# • Tests use 'request(app)' instead of 'request.agent(app)' — cookies don't persist
# • Mocking the CSRF middleware in tests → tests never exercise the real defence
# • Forgetting to reload the CSRF token after login (rotation) → test passes against stale value
# • Assertions like 'not 200' — too loose; assert 403 specifically
# • CORS test passes locally (no Origin) but fails in CI behind a proxy
# • Forgetting double-submit cookie reads vs writes — test both directions
# • Not testing OPTIONS preflight — CORS misconfigs hide here
# • Disabling forgery protection in test config 'for convenience' — defeats the entire test
Why it matters
Every state-changing endpoint needs CSRF tests: missing token = 403, valid token = 2xx, mismatched origin not approved by CORS, login rotates the token. Wire them into CI, snapshot the security header set, and add a route-loop test so every new endpoint is automatically covered.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
test('rejects cross-origin POST without CSRF token', async () => {
const res = await request(app).post('/transfer')
.set('Cookie', session)
.set('Origin', 'https://evil.example')
.send({ to: 'attacker', amount: 1 });
expect(res.status).toBe(403);
});
Try it Yourself »
Exercise
Status expected for a missing CSRF token.
expect(res.status).toBe(
);
Three digits.
Discussion
Loading…