How to fix Cross-Site Request Forgery (CSRF)
CSRF tricks a logged-in user's browser into making authenticated state-changing requests they didn't intend. Modern defenses are simpler than people think — SameSite=Lax cookies fix 95% of cases automatically. The remaining 5% need CSRF tokens. Here's the complete playbook.
What is CSRF (Cross-Site Request Forgery)?
CSRF works because browsers automatically attach cookies to every request to a domain — including requests originating from other origins. If user.com is logged into bank.com (session cookie set), and user.com renders `<form action='https://bank.com/transfer' method=POST><input name=amount value=10000><input name=to value=attacker></form><script>document.forms[0].submit()</script>`, the browser submits that form WITH the bank session cookie. Bank sees a valid authenticated request and processes the transfer.
The attack requires three things: (1) target is logged in (session cookie present), (2) target visits attacker page, (3) target's bank doesn't verify the request originated from its own forms. Defense is breaking link 3 — either by requiring an unguessable token in the request (CSRF tokens) or by configuring cookies to not be sent on cross-site requests (SameSite=Lax).
CSRF used to require complex form submissions, but `fetch()` made it easier and `application/json` made the old `Content-Type: form-urlencoded`-based defenses irrelevant. Modern CSRF lives in JSON APIs that use cookie auth — exactly the pattern most SPAs accidentally implement.
What an attacker can do
The concrete impact of leaving CSRF (Cross-Site Request Forgery) unpatched.
Funds transfer / state changes as the victim
Anything the user can do (change email, transfer money, delete account, post content), an attacker can trigger from a remote page.
Account takeover via email change
POST /settings/email with attacker's email → password reset to attacker → game over.
Worm propagation in social apps
Stored CSRF (XSS or DOM-based) auto-shares the attack page to the victim's contacts.
Privilege escalation in admin panels
Admin visits attacker's page, attacker triggers admin-only operations using admin session.
How do I know if I'm vulnerable?
Manual: check every state-changing endpoint (POST/PUT/PATCH/DELETE) for either (a) explicit CSRF token validation or (b) requirement of a non-cookie credential (Bearer header). If the only auth is session cookie and there's no token, you have CSRF.
Automated: AuditCore's authenticated crawler maps state-changing endpoints, then attempts to call them from a different origin (no cookies, no Referer to your domain). Endpoints that succeed without auth verification = CSRF. The Auth Replay scanner catches related issues (BFLA, missing role checks).
How to fix CSRF (Cross-Site Request Forgery)
5 ordered steps. Apply them in order — each builds on the previous.
- 1
Set SameSite=Lax (or Strict) on session cookies
SameSite=Lax tells the browser not to send the cookie on cross-site POST/PUT/DELETE requests. Top-level GET navigations still send it (so users clicking your-app links from elsewhere stay logged in).
Chrome defaults to SameSite=Lax for cookies without explicit attribute since 2020. Firefox followed. Safari has stricter defaults. Set explicitly to ensure consistent behavior. Use Strict for non-shared apps where users never navigate from external links.
javascriptStep 1// Express app.use(session({ name: "sid", cookie: { httpOnly: true, secure: true, sameSite: "lax", // or "strict" maxAge: 1000 * 60 * 60 * 24 * 7, }, })); // Set-Cookie header equivalent: // Set-Cookie: sid=abc; HttpOnly; Secure; SameSite=Lax; Path=/ // Django: # settings.py SESSION_COOKIE_SAMESITE = "Lax" SESSION_COOKIE_SECURE = True CSRF_COOKIE_SAMESITE = "Lax" - 2
Add CSRF tokens for forms (synchronizer pattern)
Server generates a per-session token, embeds in form, validates on submission. Belt-and-suspenders alongside SameSite.
Most frameworks have built-in CSRF middleware. Django's `{% csrf_token %}`, Rails' `csrf_meta_tags`, Express's `csurf` (deprecated but widely used; alternatives like `csrf-csrf` are maintained). Use them.
pythonStep 2# Django — built-in # settings.py MIDDLEWARE = [..., 'django.middleware.csrf.CsrfViewMiddleware'] # In template <form method="post"> {% csrf_token %} <input name="email" value="{{ user.email }}"> <button>Save</button> </form> # AJAX — read token from cookie + send in header fetch("/api/save", { method: "POST", headers: { "X-CSRFToken": getCookie("csrftoken") }, body: JSON.stringify({...}) }); - 3
Use double-submit cookie pattern for stateless APIs
When you can't keep server-side session state, the client reads a CSRF token from a cookie and sends it in a custom header. Browser never lets cross-origin pages read the cookie, so cross-origin requests can't include the matching header.
Common pattern for SPAs. Token can be HMAC-signed for tamper resistance.
javascriptStep 3// Server: set CSRF cookie on login + every authenticated response res.cookie("csrf-token", crypto.randomBytes(32).toString("hex"), { secure: true, sameSite: "lax", // NOT httpOnly — JS needs to read it }); // Client: read cookie, send as header const csrfToken = document.cookie.match(/csrf-token=([^;]+)/)?.[1]; fetch("/api/transfer", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken, }, body: JSON.stringify({ amount, to }), credentials: "include", }); // Server middleware verifies header matches cookie function csrfCheck(req, res, next) { const headerToken = req.get("X-CSRF-Token"); const cookieToken = req.cookies["csrf-token"]; if (!headerToken || !cookieToken || headerToken !== cookieToken) { return res.sendStatus(403); } next(); } - 4
Use Bearer token auth for APIs (not cookies)
If your API uses Authorization: Bearer headers, CSRF is structurally impossible — cross-origin requests can't read or set arbitrary headers due to CORS preflight.
Move from session cookies to JWT/OAuth Bearer tokens stored in memory (NOT localStorage — XSS reads it). Token gets sent on every API call manually. Caveat: lose CSRF immunity if you fall back to cookies for any endpoint.
javascriptStep 4// Login returns Bearer token in response body const { token } = await fetch("/login", { method: "POST", body: ... }).then(r => r.json()); // Store in memory (not localStorage — XSS hazard) let authToken = token; // Send on every API call const res = await fetch("/api/transfer", { headers: { "Authorization": `Bearer ${authToken}` }, body: ..., }); // Cross-origin pages CANNOT read your token. CSRF eliminated. - 5
Verify Origin/Referer headers as defense-in-depth
Most state-changing requests should originate from your own domain. Reject requests where Origin doesn't match.
Origin is more reliable than Referer (Referer can be stripped by privacy extensions). Don't reject when Origin is missing (legitimate cases: native apps, webhooks); rather, reject when it's PRESENT and doesn't match.
javascriptStep 5function checkOrigin(req, res, next) { const origin = req.get("Origin"); if (!origin) return next(); // legitimate cases const allowed = ["https://app.example.com", "https://example.com"]; if (!allowed.includes(origin)) { return res.status(403).json({ error: "Cross-origin request blocked" }); } next(); } app.use(["/api/transfer", "/api/admin"], checkOrigin);
How to verify the fix
Run AuditCore — its authenticated crawler maps state-changing endpoints, then attempts to call them with no cookies and from a different Origin. Findings include which endpoints accept cross-origin requests.
Manual: open your browser's dev tools, go to a different domain (e.g., example.org). In console: `await fetch('https://your-app.com/api/changeEmail', { method: 'POST', body: JSON.stringify({email:'[email protected]'}), headers: {'Content-Type': 'application/json'}, credentials: 'include' })`. If your endpoint processes this, you have CSRF.
Frequently asked questions
Is CSRF still relevant in 2026 with SameSite=Lax default?+
Yes. SameSite=Lax handles top-level POST/PUT/DELETE from cross-origin pages, but doesn't handle: subdomain confusion (attacker controls a subdomain), GET-based state changes (legacy APIs that mutate on GET), or apps that explicitly set SameSite=None for embedded use cases. Defense-in-depth with tokens still recommended.
Should I use CSRF tokens AND SameSite cookies?+
Yes — they protect against different scenarios. SameSite catches most cross-site requests at the browser level. Tokens catch the cases SameSite misses (subdomains, certain navigation patterns) and provide a clear audit trail (server-side logs show the validation failure).
Does using JSON APIs (not form-urlencoded) protect against CSRF?+
Used to (because cross-origin form posts couldn't send Content-Type: application/json without CORS preflight). Modern fetch() can include arbitrary content types if the server allows the preflight. If your server has permissive CORS, JSON content-type is no longer a CSRF defense.
What about CSRF in mobile apps?+
Mobile apps don't use cookies (they use Bearer tokens), so CSRF doesn't apply. The CSRF threat model is browser-specific because browsers auto-attach cookies. Native apps don't have that auto-attach behavior.
Does my GraphQL API need CSRF protection?+
If it accepts cookies for auth: yes, same rules apply. If it requires Authorization header: no, CSRF doesn't apply because cross-origin pages can't add arbitrary headers without preflight + CORS approval. Most modern GraphQL implementations use Bearer tokens precisely to avoid CSRF.
Related fix guides
How to fix CORS misconfiguration
Loose CORS undermines CSRF protection. The two go together.
Read guideHow to fix XSS
XSS bypasses CSRF tokens (steals them). Fix XSS first; CSRF tokens only help if XSS isn't a thing.
Read guideHow to fix JWT alg:none
Switching to Bearer-token auth eliminates CSRF — but JWT misuse opens its own attack class.
Read guide