Sitemap

CSRF Attacks Explained: Complete Prevention Guide for Express.js, Node.js, and Django

--

Everything you need to know about CSRF — how the attack works, all attack variants, and complete prevention code for Express.js, pure Node.js, and Django. With real examples.

There is a class of web vulnerability so well-understood, so thoroughly documented, and so consistently present in security audits that its continued existence in production applications is almost embarrassing. Cross-Site Request Forgery — CSRF — has been on the OWASP Top 10 list for over a decade. Frameworks have built-in protection for it. Libraries make it trivial to implement. And yet developers still ship applications vulnerable to it, often because they understand the fix without understanding the attack.

This post explains CSRF from first principles — what it is, exactly how the attack works at the HTTP level, why browsers make it possible, and then how to prevent it correctly in Express.js, Node.js from scratch, and Django. Real examples, real code, real edge cases.

What CSRF actually is

CSRF exploits one specific browser behaviour: when a browser makes a request to a domain, it automatically includes all cookies for that domain — regardless of where the request originates.

Read that again slowly. A request from evil.com to bank.com will include your bank.com session cookie. The browser does not check whether the page initiating the request is trustworthy. It just sends the cookies.

This means: if you are logged into bank.com and you visit evil.com, and evil.com makes a request to bank.com on your behalf, the bank's server receives a request with your valid session cookie — and has no way to tell whether you made it or whether evil.com made it.

CSRF is not about stealing cookies. The attacker never sees the cookie. They just trigger a request that carries your cookie automatically. The damage is done without the attacker ever reading your credentials.

A complete attack walkthrough

Let’s build the attack step by step so the mechanism is completely clear.

The target application

bank.com has a money transfer endpoint:

POST /transfer
Cookie: session=abc123xyz
amount=5000&to_account=1234567890

The server receives this request, sees a valid session cookie, trusts the user, and transfers ₹5000 to account 1234567890.

The attacker’s page

The attacker creates evil.com with this HTML:

<!-- evil.com/steal.html -->
<!DOCTYPE html>
<html>
<body>
<h1>You won a prize! Click below to claim it.</h1>
  <!-- This form auto-submits on page load -->
<form id="csrf-form" action="https://bank.com/transfer" method="POST">
<input type="hidden" name="amount" value="50000">
<input type="hidden" name="to_account" value="9999999999">
</form>
<script>
document.getElementById("csrf-form").submit();
</script>
</body>
</html>

What happens when you visit evil.com

  1. Your browser loads evil.com/steal.html
  2. The JavaScript immediately submits the hidden form
  3. Your browser sends a POST request to https://bank.com/transfer
  4. Your browser automatically attaches your bank.com session cookie to this request
  5. bank.com's server sees a valid session, processes the transfer
  6. ₹50,000 leaves your account

You see a page load. Maybe a redirect. You have no idea what just happened.

The attacker never touched your cookie. They never logged into your account. They just made your browser do it for them.

Attack variants: more than just forms

Most CSRF explanations stop at the form example. Real attackers have more options.

GET-based CSRF (the simplest)

If a state-changing operation uses GET (which it shouldn’t, but many do):

<!-- As simple as an image tag -->
<img src="https://bank.com/transfer?amount=50000&to=9999999999"
width="0" height="0">

The moment your browser renders this page, it fetches that URL — including your cookies. The “image” is 0×0 pixels. You see nothing. The transfer happens.

This is why GET requests must never change server state. It’s not just REST convention — it’s security.

JSON API CSRF

Many developers think “my API uses JSON, not forms, so I’m safe from CSRF.” This is wrong.

<!-- Attacking a JSON API with a form -->
<form action="https://api.bank.com/transfer" method="POST"
enctype="text/plain">
<input name='{"amount":50000,"to":"9999"' value='"}'>
</form>

This submits with Content-Type: text/plain and a body of {"amount":50000,"to":"9999"} — valid JSON, delivered via CSRF.

Or with XMLHttpRequest, if CORS is misconfigured:

// evil.com
const xhr = new XMLHttpRequest();
xhr.open("POST", "https://bank.com/api/transfer");
xhr.withCredentials = true; // include cookies
xhr.setRequestHeader("Content-Type", "application/json");
xhr.send(JSON.stringify({ amount: 50000, to: "9999999999" }));

This only works if bank.com has Access-Control-Allow-Credentials: true with a permissive CORS policy — but many APIs do.

Login CSRF

Even login forms are vulnerable. The attacker creates a form that logs you into their account:

<form action="https://site.com/login" method="POST">
<input type="hidden" name="username" value="attacker">
<input type="hidden" name="password" value="attackerpassword">
</form>
<script>document.forms[0].submit();</script>

Now you’re logged into the attacker’s account without knowing it. Any data you enter (search history, payment info, personal details) goes into their account. This is how some phishing attacks work — they don’t steal your credentials, they make you accidentally use theirs.

Stored CSRF

If an attacker can inject HTML into your application (via a comment field, profile description, etc.) and your XSS filtering is incomplete:

<!-- Stored in a comment on forum.com -->
<img src="https://bank.com/transfer?amount=50000&to=9999"
style="display:none">

Now anyone who reads that forum post also makes the transfer — without ever leaving forum.com.

Why the Same-Origin Policy doesn’t save you

The Same-Origin Policy (SOP) restricts what JavaScript can read from cross-origin responses. It does not restrict what browsers can send to other origins. Forms and simple requests have been able to cross origins since the beginning of the web — long before the Same-Origin Policy existed. The SOP was layered on top without breaking that existing behaviour.

So: evil.com's JavaScript cannot read the response from bank.com. But it can absolutely trigger the request. The damage in a CSRF attack is the request being made, not the response being read.

CORS (Cross-Origin Resource Sharing) extends this model — it gives servers a way to allow JavaScript from specific origins to read responses. But by default, simple form requests still go through without CORS. The cookie is still sent. The state change still happens. The attacker just can’t read the result — which they usually don’t need to.

The defence: CSRF tokens

The core defence is: prove the request originated from your own page, not from a third-party page.

A CSRF token accomplishes this. It works like this:

  1. When the server renders a form, it generates a secret, random, unpredictable token
  2. It embeds that token in the form (as a hidden field) and stores it server-side (in the session)
  3. When the form is submitted, the client sends back the token in the request body
  4. The server checks: does the token in the body match the token in the session?
  5. If yes — the request came from a form the server rendered. Safe.
  6. If no, or missing — the request came from somewhere else. Reject it.

An attacker on evil.com cannot forge a valid token because:

  • They can’t read the token from bank.com's pages (SOP blocks cross-origin reads)
  • The token is random and unpredictable — they can’t guess it
  • The token changes per session or per form — old tokens are invalid

This breaks the attack completely. Even though the attacker can trigger a request that carries your cookies, they cannot include a valid CSRF token in the body — they don’t know what it is.

Prevention in Express.js

Setup

npm install express express-session csurf cookie-parser

Note: csurf has been deprecated. For new projects, use csrf-csrf or implement the Double Submit Cookie pattern manually. We'll show both.

Option 1: csrf-csrf (modern, maintained)

npm install csrf-csrf cookie-parser
// server.js
const express = require("express");
const cookieParser = require("cookie-parser");
const { doubleCsrf } = require("csrf-csrf");
const app = express();app.use(cookieParser(process.env.COOKIE_SECRET || "your-secret-key"));
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
const {
generateToken,
doubleCsrfProtection,
} = doubleCsrf({
getSecret: () => process.env.CSRF_SECRET || "csrf-secret-key",
cookieName: "__Host-csrf",
cookieOptions: {
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
httpOnly: true,
},
size: 64,
getTokenFromRequest: (req) =>
req.body._csrf || req.headers["x-csrf-token"],
});
// Route to get CSRF token (for SPAs)
app.get("/csrf-token", (req, res) => {
res.json({ csrfToken: generateToken(req, res) });
});
// Apply CSRF protection to state-changing routes
app.post("/transfer", doubleCsrfProtection, transferHandler);
app.put("/profile", doubleCsrfProtection, updateProfileHandler);
app.delete("/account", doubleCsrfProtection, deleteAccountHandler);

Option 2: Manual Double Submit Cookie Pattern

The double submit cookie pattern doesn’t require server-side token storage. The server sets a CSRF cookie, and the client reads it and sends it back as a request header. Since evil.com can't read bank.com's cookies (SOP), they can't extract and resend it.

// csrf-middleware.js
const crypto = require("crypto");
function generateCsrfToken() {
return crypto.randomBytes(32).toString("hex");
}
function setCsrfCookie(req, res) {
let token = req.cookies["csrf-token"];
if (!token) {
token = generateCsrfToken();
res.cookie("csrf-token", token, {
httpOnly: false, // must be readable by JS so the client can send it
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
});
}
return token;
}
function csrfProtection(req, res, next) {
// Skip safe methods
if (["GET", "HEAD", "OPTIONS"].includes(req.method)) {
return next();
}
const cookieToken = req.cookies["csrf-token"];
const headerToken = req.headers["x-csrf-token"];
const bodyToken = req.body?._csrf;
const requestToken = headerToken || bodyToken;
if (!cookieToken || !requestToken) {
return res.status(403).json({ error: "CSRF token missing" });
}
// Constant-time comparison to prevent timing attacks
const cookieBuf = Buffer.from(cookieToken);
const requestBuf = Buffer.from(requestToken);
if (
cookieBuf.length !== requestBuf.length ||
!crypto.timingSafeEqual(cookieBuf, requestBuf)
) {
return res.status(403).json({ error: "CSRF token invalid" });
}
next();
}
module.exports = { setCsrfCookie, csrfProtection, generateCsrfToken };
// server.js
const express = require("express");
const cookieParser = require("cookie-parser");
const { setCsrfCookie, csrfProtection } = require("./csrf-middleware");
const app = express();
app.use(cookieParser());
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
// Set CSRF cookie on every GET (so it's ready before forms)
app.use((req, res, next) => {
if (req.method === "GET") setCsrfCookie(req, res);
next();
});
// Apply protection to all mutating routes
app.use(csrfProtection);

Server-rendered forms with CSRF token

const session = require("express-session");
const crypto = require("crypto");
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, secure: true, sameSite: "strict" }
}));
// Generate token and embed in form
app.get("/transfer", (req, res) => {
const csrfToken = crypto.randomBytes(32).toString("hex");
req.session.csrfToken = csrfToken;
res.send(`
<!DOCTYPE html>
<html>
<body>
<form action="/transfer" method="POST">
<input type="hidden" name="_csrf" value="${csrfToken}">
<label>Amount: <input type="number" name="amount"></label>
<label>To account: <input type="text" name="to_account"></label>
<button type="submit">Transfer</button>
</form>
</body>
</html>
`);
});
// Validate token on submission
app.post("/transfer", (req, res) => {
const { _csrf, amount, to_account } = req.body;
if (!_csrf || !req.session.csrfToken) {
return res.status(403).send("CSRF token missing");
}
const sessionBuf = Buffer.from(req.session.csrfToken);
const bodyBuf = Buffer.from(_csrf);
if (
sessionBuf.length !== bodyBuf.length ||
!require("crypto").timingSafeEqual(sessionBuf, bodyBuf)
) {
return res.status(403).send("CSRF token invalid");
}
// Invalidate token after use (synchronizer token pattern)
delete req.session.csrfToken;
// Process the transfer...
res.send(`Transferred ₹${amount} to account ${to_account}`);
});

CSRF protection for Single Page Applications (React, Vue, Angular)

SPAs don’t use server-rendered forms. They make API calls with fetch or axios. The pattern is:

Get Chetan Chauhan’s stories in your inbox

Join Medium for free to get updates from this writer.

Step 1: On app load, fetch the CSRF token

// api.js (frontend)
let csrfToken = null;
async function getCsrfToken() {
if (csrfToken) return csrfToken;
const res = await fetch("/csrf-token", { credentials: "include" });
const data = await res.json();
csrfToken = data.csrfToken;
return csrfToken;
}

Step 2: Include the token in every mutating request

async function transferMoney(amount, toAccount) {
const token = await getCsrfToken();
  const res = await fetch("/api/transfer", {
method: "POST",
credentials: "include", // send cookies
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": token, // send CSRF token in header
},
body: JSON.stringify({ amount, toAccount })
});
return res.json();
}

Step 3: Axios interceptor (set it once, applies everywhere)

// axios-setup.js
import axios from "axios";
// Fetch token once
const { data } = await axios.get("/csrf-token");
const csrfToken = data.csrfToken;
// Add to every request automatically
axios.interceptors.request.use(config => {
if (!["get", "head", "options"].includes(config.method)) {
config.headers["X-CSRF-Token"] = csrfToken;
}
return config;
});

Prevention in pure Node.js (no Express)

If you’re building without a framework, you handle CSRF in the raw http module. This shows the mechanism clearly.

// server.js
const http = require("http");
const crypto = require("crypto");
const url = require("url");
const qs = require("querystring");
// Simple in-memory session store (use Redis in production)
const sessions = new Map();
function getSession(req) {
const cookies = parseCookies(req.headers.cookie || "");
const sid = cookies["session_id"];
return sid ? sessions.get(sid) : null;
}
function parseCookies(cookieHeader) {
return Object.fromEntries(
cookieHeader.split(";").map(c => c.trim().split("=").map(decodeURIComponent))
);
}
function generateToken() {
return crypto.randomBytes(32).toString("hex");
}
function validateCsrfToken(session, bodyToken) {
if (!session?.csrfToken || !bodyToken) return false;
const a = Buffer.from(session.csrfToken);
const b = Buffer.from(bodyToken);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const server = http.createServer((req, res) => {
const parsed = url.parse(req.url, true);
const path = parsed.pathname;
const method = req.method;
// Serve the transfer form
if (method === "GET" && path === "/transfer") {
let session = getSession(req);
if (!session) {
// Create a new session (in real apps, do this at login)
const sid = crypto.randomBytes(16).toString("hex");
session = { userId: 1 };
sessions.set(sid, session);
res.setHeader("Set-Cookie",
`session_id=${sid}; HttpOnly; SameSite=Strict; Path=/`
);
}
// Generate CSRF token and store in session
const csrfToken = generateToken();
session.csrfToken = csrfToken;
res.writeHead(200, { "Content-Type": "text/html" });
res.end(`
<form method="POST" action="/transfer">
<input type="hidden" name="_csrf" value="${csrfToken}">
<input type="number" name="amount" placeholder="Amount">
<input type="text" name="to_account" placeholder="Account number">
<button type="submit">Transfer</button>
</form>
`);
return;
}
// Handle transfer submission
if (method === "POST" && path === "/transfer") {
const session = getSession(req);
if (!session) {
res.writeHead(401);
res.end("Not authenticated");
return;
}
let body = "";
req.on("data", chunk => { body += chunk; });
req.on("end", () => {
const params = qs.parse(body);
if (!validateCsrfToken(session, params._csrf)) {
res.writeHead(403, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "CSRF validation failed" }));
return;
}
// Token is valid — invalidate it immediately (one-time use)
delete session.csrfToken;
// Process transfer
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
success: true,
message: `Transferred ₹${params.amount} to ${params.to_account}`
}));
});
return;
}
res.writeHead(404);
res.end("Not found");
});
server.listen(3000, () => console.log("Server at http://localhost:3000"));

This shows exactly what frameworks abstract away: cookie parsing, session management, token generation and validation, constant-time comparison. The logic is the same — the framework just packages it.

Prevention in Django

Django has CSRF protection built in and enabled by default. Understanding how it works lets you use it correctly rather than accidentally disabling it.

How Django’s CSRF middleware works

When Django’s CsrfViewMiddleware is active (it's in MIDDLEWARE by default):

  1. For GET requests: Django sets a csrftoken cookie if one doesn't exist
  2. For POST/PUT/DELETE requests: Django checks that the submitted csrfmiddlewaretoken field (or X-CSRFToken header) matches the cookie value
  3. If they don’t match: Django returns 403 Forbidden
# settings.py — default Django setup, CSRF is on
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", # this is the CSRF middleware
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]

Django templates: {% csrf_token %}

In every form, add the template tag:

<!-- templates/transfer.html -->
<form method="post" action="/transfer/">
{% csrf_token %}
<!-- This renders as: -->
<!-- <input type="hidden" name="csrfmiddlewaretoken" value="random-token-here"> -->
  <label>Amount: <input type="number" name="amount"></label>
<label>To account: <input type="text" name="to_account"></label>
<button type="submit">Transfer</button>
</form>

That single tag is all you need for server-rendered forms. Django handles generation, storage, and validation.

Django views

# views.py
from django.shortcuts import render, redirect
from django.views.decorators.http import require_http_methods
from django.contrib.auth.decorators import login_required
@login_required
@require_http_methods(["GET", "POST"])
def transfer_view(request):
if request.method == "GET":
return render(request, "transfer.html")
# Django's middleware has already validated the CSRF token by the time
# we get here. If it was invalid, we never reach this code.
amount = request.POST.get("amount")
to_account = request.POST.get("to_account")
# Process transfer
perform_transfer(request.user, amount, to_account)
return redirect("/success/")
# urls.py
from django.urls import path
from . import views
urlpatterns = [
path("transfer/", views.transfer_view, name="transfer"),
]

Django REST Framework: CSRF for APIs

DRF has session authentication, and CSRF applies when using it. For API views:

# views.py
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from rest_framework.authentication import SessionAuthentication
class EnforcingCSRFSessionAuthentication(SessionAuthentication):
"""
By default, DRF's SessionAuthentication enforces CSRF.
This subclass makes it explicit and adds extra logging.
"""
def enforce_csrf(self, request):
# This calls Django's CSRF check
super().enforce_csrf(request)
class TransferView(APIView):
authentication_classes = [EnforcingCSRFSessionAuthentication]
permission_classes = [IsAuthenticated]
def post(self, request):
# CSRF already validated by SessionAuthentication
amount = request.data.get("amount")
to_account = request.data.get("to_account")
perform_transfer(request.user, amount, to_account)
return Response({"status": "success"})

Django + React/Vue SPA

For SPAs consuming a Django backend:

# settings.py
CSRF_COOKIE_HTTPONLY = False # JS needs to read the cookie
CSRF_COOKIE_SAMESITE = "Strict"
CSRF_TRUSTED_ORIGINS = [
"https://yourapp.com",
"https://www.yourapp.com",
]
# For development
if DEBUG:
CSRF_TRUSTED_ORIGINS += ["http://localhost:3000"]
// frontend/api.js
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(";").shift();
}
async function apiPost(url, data) {
const csrfToken = getCookie("csrftoken"); // Django's cookie name
const response = await fetch(url, {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": csrfToken, // Django checks this header
},
body: JSON.stringify(data),
});
return response.json();
}
// Usage
apiPost("/api/transfer/", { amount: 5000, to_account: "1234567890" });
// Axios configuration for Django
import axios from "axios";
function getCookie(name) {
const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`));
return match ? match[2] : null;
}
axios.defaults.withCredentials = true;
axios.defaults.headers.common["X-CSRFToken"] = getCookie("csrftoken");
// Refresh token if it changes (e.g., after login)
axios.interceptors.request.use(config => {
config.headers["X-CSRFToken"] = getCookie("csrftoken");
return config;
});

Django: exempting specific views

Sometimes you need to exempt a view — a public webhook endpoint, for example, that receives POST requests from external services that can’t include a CSRF token.

from django.views.decorators.csrf import csrf_exempt, csrf_protect
from django.views import View
from django.utils.decorators import method_decorator
# Function-based view exemption
@csrf_exempt
def stripe_webhook(request):
# Stripe sends POST requests — they can't include a CSRF token
# Instead, validate using Stripe's webhook signature
payload = request.body
sig_header = request.META.get("HTTP_STRIPE_SIGNATURE")
try:
event = stripe.Webhook.construct_event(
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
)
except ValueError:
return HttpResponse(status=400)
except stripe.error.SignatureVerificationError:
return HttpResponse(status=400)
# Process the webhook event
handle_stripe_event(event)
return HttpResponse(status=200)
# Class-based view exemption
@method_decorator(csrf_exempt, name="dispatch")
class WebhookView(View):
def post(self, request):
# validate webhook signature here
return HttpResponse("OK")
# Re-applying protection inside an exempted zone
@csrf_exempt
def api_root(request):
# This route is exempt, but a subroute is protected
pass
@csrf_protect
def protected_inside_api(request):
# Protected even if parent is exempt
pass

Never exempt views that perform state changes for authenticated users. Only exempt endpoints that receive authenticated requests through non-cookie mechanisms (API keys, webhook signatures, JWT in Authorization header).

The SameSite cookie attribute: a modern defence layer

SameSite is a cookie attribute that tells browsers when to include a cookie in cross-site requests. It's a second line of defence — not a replacement for CSRF tokens.

SameSite=Strict   → Cookie NEVER sent on cross-site requests
SameSite=Lax → Cookie sent only on top-level navigations (GET links),
not on form submits or AJAX from other sites
SameSite=None → Cookie always sent (requires Secure flag)

In Express:

app.use(session({
secret: process.env.SESSION_SECRET,
cookie: {
httpOnly: true,
secure: true,
sameSite: "strict" // blocks CSRF entirely for modern browsers
}
}));

In Django:

# settings.py
SESSION_COOKIE_SAMESITE = "Strict"
CSRF_COOKIE_SAMESITE = "Strict"
SESSION_COOKIE_SECURE = True # HTTPS only
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True

Why SameSite alone isn't enough:

  • Older browsers (IE11, some older mobile browsers) don’t support it
  • SameSite=Lax (the default in most modern browsers) still allows GET requests
  • Subdomain attacks: if you control a.example.com and b.example.com shares cookies, a can CSRF b — same site, different subdomain
  • You can’t rely on browser behaviour for security — defence in depth means CSRF tokens plus SameSite

Comparing CSRF handling across platforms

┌──────────────────────┬───────────────────────────────┬────────────────────────────────┐
│ │ Express.js / Node.js │ Django │
├──────────────────────┼───────────────────────────────┼────────────────────────────────┤
│ Built-in protection │ No — must add middleware │ Yes — CsrfViewMiddleware │
│ Default state │ Off — opt-in │ On — opt-out │
│ Template integration │ Manual hidden field │ {% csrf_token %} tag │
│ SPA integration │ X-CSRF-Token header │ X-CSRFToken header │
│ Token storage │ Session or double-submit cookie│ Cookie (double-submit pattern) │
│ Exempting views │ Don't apply middleware │ @csrf_exempt decorator │
│ Cookie name │ Your choice │ csrftoken (configurable) │
│ Header name │ X-CSRF-Token (convention) │ X-CSRFToken (built-in) │
│ Constant-time compare│ Must implement manually │ Built into middleware │
│ Trusted origins │ Configure CORS │ CSRF_TRUSTED_ORIGINS setting │
└──────────────────────┴───────────────────────────────┴────────────────────────────────┘

The fundamental difference: Django’s CSRF protection is opt-out — you have it unless you explicitly remove it. Express’s is opt-in — you have to add and configure it yourself. This is why Django applications tend to have fewer CSRF vulnerabilities out of the box, and why Express developers need to be deliberate about it.

Common mistakes that break CSRF protection

Mistake 1: Putting the CSRF token in a URL parameter

❌ GET /transfer?_csrf=abc123&amount=5000
Tokens in URLs appear in:
- Browser history
- Referrer headers
- Server logs
- Proxy logs

CSRF tokens belong in request bodies or custom headers. Never in URLs.

Mistake 2: Using the same token for the entire application lifetime

// ❌ Token never changes — if it leaks once, it works forever
app.use((req, res, next) => {
if (!req.session.csrfToken) {
req.session.csrfToken = crypto.randomBytes(32).toString("hex");
}
next();
});
// ✅ Generate a new token per form render, invalidate after use
app.get("/sensitive-form", (req, res) => {
req.session.csrfToken = crypto.randomBytes(32).toString("hex");
// render form with token
});
app.post("/sensitive-form", (req, res) => {
// validate token
delete req.session.csrfToken; // invalidate immediately
});

Mistake 3: Not using timingSafeEqual

// ❌ String comparison is vulnerable to timing attacks
if (req.session.csrfToken === req.body._csrf) { ... }
// ✅ Constant-time comparison
const crypto = require("crypto");
const a = Buffer.from(req.session.csrfToken || "");
const b = Buffer.from(req.body._csrf || "");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(403).json({ error: "Invalid CSRF token" });
}

A timing attack works by measuring how long a comparison takes. === short-circuits the moment characters don't match — a completely wrong token fails faster than one that matches the first 31 characters. An attacker can use this timing difference to guess the token one character at a time. timingSafeEqual always takes the same time regardless of where the mismatch is.

Mistake 4: Accepting CSRF tokens via GET parameters in APIs

// ❌ Dangerous — GET requests are logged, cached, bookmarked
app.get("/api/data", (req, res) => {
const token = req.query.csrf_token; // never do this
});
// ✅ Custom headers only for APIs
app.post("/api/data", (req, res) => {
const token = req.headers["x-csrf-token"];
});

Mistake 5: Trusting Content-Type: application/json as CSRF protection

// ❌ This is not safe — see JSON CSRF attack above
app.post("/api/transfer", (req, res) => {
if (req.headers["content-type"] !== "application/json") {
return res.status(400).json({ error: "Wrong content type" });
}
// This does NOT prevent CSRF
});

Content-Type can be set by XMLHttpRequest from a cross-origin page if CORS allows it. It’s not a CSRF defence.

Mistake 6: Wildcard CORS with credentials

// ❌ Fatal combination — allows any site to read responses AND send cookies
app.use(cors({
origin: "*", // allow any origin
credentials: true // with cookies
}));
// ✅ Explicit origin whitelist
app.use(cors({
origin: ["https://yourapp.com", "https://www.yourapp.com"],
credentials: true
}));

Browsers actually block origin: "*" with credentials: true — but some CORS libraries handle this poorly, or developers work around the error without understanding it.

A complete working example: banking app

Here’s a minimal but complete Express application with CSRF protection, demonstrating every piece working together:

// app.js — Complete example with CSRF protection
const express = require("express");
const session = require("express-session");
const crypto = require("crypto");
const path = require("path");
const app = express();app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(session({
secret: process.env.SESSION_SECRET || "dev-secret-change-in-prod",
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
maxAge: 1000 * 60 * 60 // 1 hour
}
}));
// --- CSRF utilities ---function generateCsrfToken() {
return crypto.randomBytes(32).toString("hex");
}
function validateCsrfToken(sessionToken, requestToken) {
if (!sessionToken || !requestToken) return false;
const a = Buffer.from(sessionToken);
const b = Buffer.from(requestToken);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
function csrfMiddleware(req, res, next) {
if (["GET", "HEAD", "OPTIONS"].includes(req.method)) return next();
const sessionToken = req.session.csrfToken;
const bodyToken = req.body._csrf;
const headerToken = req.headers["x-csrf-token"];
const requestToken = bodyToken || headerToken;
if (!validateCsrfToken(sessionToken, requestToken)) {
return res.status(403).json({ error: "CSRF validation failed" });
}
// Rotate token after successful validation
req.session.csrfToken = generateCsrfToken();
next();
}
// --- Simulated user data ---
let balance = 100000;
const transactions = [];
// --- Routes ---// Fake login (sets session)
app.post("/login", (req, res) => {
req.session.userId = 1;
req.session.username = "Aarav Shah";
req.session.csrfToken = generateCsrfToken();
res.json({ message: "Logged in", csrfToken: req.session.csrfToken });
});
// Get CSRF token (for SPAs)
app.get("/csrf-token", (req, res) => {
if (!req.session.userId) return res.status(401).json({ error: "Not authenticated" });
res.json({ csrfToken: req.session.csrfToken });
});
// Transfer form (server-rendered)
app.get("/transfer", (req, res) => {
if (!req.session.userId) return res.redirect("/login");
const token = generateCsrfToken();
req.session.csrfToken = token;
res.send(`
<!DOCTYPE html>
<html>
<head><title>Transfer</title></head>
<body>
<h1>Transfer Money</h1>
<p>Balance: ₹${balance}</p>
<form method="POST" action="/transfer">
<input type="hidden" name="_csrf" value="${token}">
<label>Amount (₹): <input type="number" name="amount" min="1" max="${balance}"></label><br>
<label>To account: <input type="text" name="to_account"></label><br>
<button type="submit">Transfer</button>
</form>
<hr>
<h2>Recent transactions</h2>
${transactions.map(t => `<p>₹${t.amount} → ${t.to} at ${t.time}</p>`).join("") || "<p>None</p>"}
</body>
</html>
`);
});
// Handle transfer
app.post("/transfer", csrfMiddleware, (req, res) => {
if (!req.session.userId) return res.status(401).json({ error: "Not authenticated" });
const amount = parseInt(req.body.amount);
const to_account = req.body.to_account;
if (!amount || amount <= 0 || amount > balance) {
return res.status(400).json({ error: "Invalid amount" });
}
if (!to_account) {
return res.status(400).json({ error: "Account number required" });
}
balance -= amount;
transactions.push({ amount, to: to_account, time: new Date().toLocaleTimeString() });
res.redirect("/transfer");
});
app.listen(3000, () => console.log("Banking app at http://localhost:3000"));

Test that it works:

# Login and get CSRF token
curl -c cookies.txt -X POST http://localhost:3000/login \
-H "Content-Type: application/json"
# Try transfer WITHOUT CSRF token — should get 403
curl -b cookies.txt -X POST http://localhost:3000/transfer \
-d "amount=1000&to_account=1234"
# Try transfer WITH CSRF token — should succeed
TOKEN=$(curl -b cookies.txt http://localhost:3000/csrf-token | jq -r '.csrfToken')
curl -b cookies.txt -X POST http://localhost:3000/transfer \
-d "amount=1000&to_account=1234&_csrf=${TOKEN}"

Summary: what to actually implement

For Express.js:

  • Use csrf-csrf package (maintained, implements double-submit cookie pattern correctly)
  • Set session cookies with httpOnly: true, sameSite: "strict", secure: true
  • Always use crypto.timingSafeEqual for token comparison
  • For SPAs: expose a /csrf-token endpoint, send token in X-CSRF-Token header
  • Never put tokens in URLs

For pure Node.js:

  • Generate tokens with crypto.randomBytes(32).toString("hex")
  • Store in server-side sessions, not client-accessible cookies
  • Validate every POST/PUT/PATCH/DELETE request
  • Invalidate tokens after use (synchronizer token pattern)

For Django:

  • Keep CsrfViewMiddleware in MIDDLEWARE — it's on by default, don't remove it
  • Add {% csrf_token %} to every form
  • For SPAs: read the csrftoken cookie, send as X-CSRFToken header
  • Only use @csrf_exempt for endpoints with alternative authentication (webhooks with signatures, API key auth)
  • Set CSRF_COOKIE_SECURE = True and CSRF_COOKIE_SAMESITE = "Strict" in production

For everyone:

  • SameSite=Strict on session cookies as an extra layer
  • Proper CORS configuration — never origin: "*" with credentials: true
  • State-changing operations must always be POST/PUT/PATCH/DELETE, never GET
  • Validate webhook POST endpoints with signatures, not CSRF tokens

CSRF is one of the oldest and most well-understood vulnerabilities in web security. The tools to prevent it are mature, well-documented, and built into most frameworks. There is no good reason for a modern web application to be vulnerable to it.

<Keep Coding />

--

--