CSRF is one of the most misunderstood web vulnerabilities. It is not dead — it has just evolved. While modern browsers have added default protections like SameSite cookies, cross-site request forgery still catches developers off guard. A missing attribute, a misconfigured cookie, or a GET endpoint that mutates state is all it takes.
This guide covers how CSRF attacks work, five concrete protection methods with code examples, framework-specific implementations for Next.js, Express, and Django, and the common mistakes that leave applications exposed.
Cross-Site Request Forgery (CSRF) is an attack that tricks an authenticated user's browser into sending an unwanted request to a web application where they are logged in. The attacker does not need to steal the user's credentials — the browser sends them automatically.
Here is the core problem: browsers attach cookies to every request made to a domain, regardless of where the request originates. If you are logged into your bank at bank.com, and you visit a malicious page, that page can submit a form to bank.com/transfer — and your browser will include your session cookie with the request.
Imagine you are logged into your banking app. Your session is stored in a cookie. You then visit a page controlled by an attacker. That page contains this hidden form:
<!-- On attacker's page: evil.com -->
<form action="https://bank.com/api/transfer" method="POST" id="csrf-form">
<input type="hidden" name="to" value="attacker-account" />
<input type="hidden" name="amount" value="5000" />
</form>
<script>document.getElementById('csrf-form').submit();</script>
When the page loads, the form auto-submits. Your browser sends a POST request to bank.com/api/transfer with your session cookie attached. The bank's server sees a valid session and processes the transfer. You never clicked anything — the attacker's page did it for you.
This works because the bank's server cannot distinguish between a legitimate request from its own frontend and a forged request from a malicious site. Both carry the same cookies.
The attack follows a predictable pattern:
CSRF attacks target state-changing operations: form submissions, API calls that modify data, account settings changes, and financial transactions. They do not give the attacker access to the response — they just trigger the action.
The most robust protection. The server generates a unique, unpredictable token for each session (or each request) and embeds it in forms. When the form is submitted, the server verifies the token matches. An attacker cannot guess the token, so their forged requests will be rejected.
How it works:
// Server: Generate and store a CSRF token
import crypto from 'crypto';
function generateCsrfToken(session) {
const token = crypto.randomBytes(32).toString('hex');
session.csrfToken = token;
return token;
}
<!-- Embed the token in every form -->
<form action="/api/transfer" method="POST">
<input type="hidden" name="_csrf" value="a1b2c3d4e5f6..." />
<input type="text" name="to" />
<input type="number" name="amount" />
<button type="submit">Transfer</button>
</form>
// Server: Verify the token on submission
function verifyCsrfToken(req, session) {
const token = req.body._csrf || req.headers['x-csrf-token'];
if (!token || token !== session.csrfToken) {
throw new Error('CSRF token validation failed');
}
}
In Next.js Server Actions, CSRF protection is built in. Server Actions automatically verify that the request originates from your application by checking the Origin header against the Host header. But if you are using API Route Handlers (app/api/), you need to implement CSRF protection manually:
// app/api/transfer/route.ts — Manual CSRF check for API routes
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
const origin = req.headers.get('origin');
const host = req.headers.get('host');
// Verify the request origin matches your domain
if (!origin || !origin.includes(host!)) {
return NextResponse.json(
{ error: 'CSRF validation failed' },
{ status: 403 }
);
}
// Process the request...
}
Strengths: Works regardless of cookie configuration. The gold standard for CSRF prevention.
Limitations: Requires server-side state (or a signed token variant). Must be included in every form and AJAX request.
The SameSite attribute on cookies controls whether the browser sends them with cross-site requests. This is the browser's built-in CSRF defense.
// Setting SameSite on a session cookie
res.cookie('session', token, {
httpOnly: true,
secure: true,
sameSite: 'Lax', // or 'Strict'
maxAge: 3600000,
});
The three values:
Strict — Cookie is never sent on cross-site requests. Even if you click a link to bank.com from Google, the cookie will not be included on that first navigation. This breaks some UX flows but provides the strongest protection.Lax — Cookie is sent on top-level navigations (clicking a link) but not on cross-site subrequests (forms, iframes, AJAX). This blocks CSRF via POST forms while preserving the "click a link and be logged in" experience. This is the default in all modern browsers since 2020.None — Cookie is always sent cross-site. Requires Secure flag. Use this only when you genuinely need cross-site cookie access (third-party embeds, OAuth flows).What Lax blocks and does not block:
| Request Type | Lax Sends Cookie? |
|---|---|
| Link click (<a href>) | Yes |
| Form GET | Yes |
| Form POST | No |
| iframe | No |
| AJAX / fetch | No |
| Image (<img>) | No |
Strengths: Zero application code needed. Enabled by default in modern browsers.
Limitations: Does not protect GET endpoints that perform state changes. Older browsers (pre-2020) do not support it. Does not protect against same-site attacks (from subdomains). Should be used as a layer, not the only defense.
Browsers enforce CORS preflight checks for requests with custom headers. A cross-origin request with a non-standard header like X-Requested-With triggers a preflight OPTIONS request. If your server does not respond with the appropriate CORS headers, the browser blocks the request entirely.
// Client: Add a custom header to every API request
fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'checkvibe-app', // Custom header
},
body: JSON.stringify({ to: 'account', amount: 100 }),
});
// Server: Reject requests without the custom header
function requireCustomHeader(req, res, next) {
if (req.headers['x-requested-with'] !== 'checkvibe-app') {
return res.status(403).json({ error: 'Missing required header' });
}
next();
}
Why this works: An attacker's form submission cannot set custom headers. A cross-origin fetch with a custom header triggers a CORS preflight, and since your server will not return Access-Control-Allow-Origin: evil.com, the browser blocks it.
Strengths: Simple to implement for single-page applications. No server-side token state needed.
Limitations: Only works for AJAX requests (not form submissions). Requires your API to reject requests without the header. Does not protect HTML form endpoints.
The Origin header tells the server which site initiated the request. By verifying it matches your domain, you can reject cross-origin requests.
// Middleware: Validate Origin header
function validateOrigin(req, res, next) {
const allowedOrigins = [
'https://yourdomain.com',
'https://www.yourdomain.com',
];
const origin = req.headers['origin'];
const referer = req.headers['referer'];
// POST/PUT/DELETE requests must have a valid origin
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
if (origin) {
if (!allowedOrigins.includes(origin)) {
return res.status(403).json({ error: 'Forbidden origin' });
}
} else if (referer) {
// Fallback to Referer header
const refererOrigin = new URL(referer).origin;
if (!allowedOrigins.includes(refererOrigin)) {
return res.status(403).json({ error: 'Forbidden origin' });
}
} else {
// No Origin or Referer — block the request
return res.status(403).json({ error: 'Missing origin' });
}
}
next();
}
Strengths: No tokens or state required. Works for all request types. This is what Next.js Server Actions use internally.
Limitations: Some privacy extensions strip the Referer header. The Origin header is not sent on some same-origin requests. You need a fallback strategy when both headers are absent.
For stateless applications that cannot store a CSRF token in a server-side session. The server sets a random token in a cookie and also expects it as a request parameter. An attacker can trigger the browser to send the cookie but cannot read its value to include it in the forged request body.
// Server: Set a CSRF cookie on page load
import crypto from 'crypto';
function setCsrfCookie(res) {
const token = crypto.randomBytes(32).toString('hex');
res.cookie('csrf-token', token, {
httpOnly: false, // JavaScript needs to read this
secure: true,
sameSite: 'Lax',
});
}
// Client: Read the cookie and send it as a header
function getCsrfToken() {
return document.cookie
.split('; ')
.find(row => row.startsWith('csrf-token='))
?.split('=')[1];
}
fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken(),
},
body: JSON.stringify({ to: 'account', amount: 100 }),
});
// Server: Compare cookie value with header value
function verifyDoubleSubmit(req, res, next) {
const cookieToken = req.cookies['csrf-token'];
const headerToken = req.headers['x-csrf-token'];
if (!cookieToken || !headerToken || cookieToken !== headerToken) {
return res.status(403).json({ error: 'CSRF validation failed' });
}
next();
}
Strengths: No server-side session state needed. Works well with REST APIs and SPAs.
Limitations: Vulnerable if the attacker can set cookies on your domain (via a subdomain vulnerability or cookie injection). The cookie must not be httpOnly since JavaScript needs to read it. Use HMAC-signed tokens for stronger protection.
Server Actions (App Router) have built-in CSRF protection. They verify the Origin header matches the Host header automatically. No configuration needed:
// app/actions.ts — Protected by default
'use server';
export async function transferFunds(formData: FormData) {
// This action is CSRF-protected automatically.
// Next.js rejects requests where Origin !== Host.
const to = formData.get('to');
const amount = formData.get('amount');
// ...
}
API Route Handlers need manual protection. Next.js does not apply CSRF checks to Route Handlers:
// middleware.ts — Add CSRF protection to API routes
import { NextRequest, NextResponse } from 'next/server';
export function middleware(req: NextRequest) {
if (req.method !== 'GET' && req.method !== 'HEAD') {
const origin = req.headers.get('origin');
const host = req.headers.get('host');
if (!origin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const originHost = new URL(origin).host;
if (originHost !== host) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
}
return NextResponse.next();
}
export const config = {
matcher: '/api/:path*',
};
Use the csrf-csrf library (the successor to the deprecated csurf):
import { doubleCsrf } from 'csrf-csrf';
import cookieParser from 'cookie-parser';
const {
generateToken,
doubleCsrfProtection,
} = doubleCsrf({
getSecret: () => process.env.CSRF_SECRET,
cookieName: '__csrf',
cookieOptions: {
httpOnly: true,
secure: true,
sameSite: 'lax',
},
getTokenFromRequest: (req) => req.headers['x-csrf-token'],
});
app.use(cookieParser());
app.use(doubleCsrfProtection);
// Endpoint to get a CSRF token for the frontend
app.get('/api/csrf-token', (req, res) => {
const token = generateToken(req, res);
res.json({ token });
});
Django has CSRF protection built into its middleware and enabled by default:
# settings.py — CSRF is on by default
MIDDLEWARE = [
'django.middleware.csrf.CsrfViewMiddleware',
# ...
]
# For APIs using custom headers instead of form tokens:
CSRF_TRUSTED_ORIGINS = [
'https://yourdomain.com',
'https://www.yourdomain.com',
]
<!-- Django templates include the token automatically -->
<form method="POST">
{% csrf_token %}
<input type="text" name="to" />
<button type="submit">Transfer</button>
</form>
For Django REST Framework API endpoints, you can use session authentication with CSRF or switch to token-based auth (which is inherently CSRF-proof since the token is in a header, not a cookie):
# For API views that need CSRF exemption (token-auth only)
from django.views.decorators.csrf import csrf_exempt
@csrf_exempt # Only safe if using non-cookie auth (Bearer tokens)
def api_view(request):
pass
SameSite Lax is the default in modern browsers and blocks most CSRF attacks. But it is not a complete solution:
/api/delete?id=123), Lax will not protect it.evil.yoursite.com), SameSite does not help because Lax considers subdomains as "same-site."Always pair SameSite with at least one other method.
GET /api/account/delete?confirm=true
This is vulnerable even with SameSite Lax because Lax allows cookies on top-level GET navigations. An attacker just needs a link. Never use GET for state-changing operations.
If an attacker exploits an XSS vulnerability on any subdomain of your application, they can:
This is why CSRF tokens stored in server-side sessions are more secure than double-submit cookies for high-security applications.
Attackers can force-logout your users using CSRF on the logout endpoint. This might seem harmless, but it can be chained with other attacks — for example, logging the user out and then presenting a phishing login page. Always protect your logout endpoint with CSRF checks, or use POST for logout instead of GET.
Never put CSRF tokens in query parameters:
<!-- BAD: Token leaked via Referer header and browser history -->
<a href="/transfer?csrf=abc123&to=savings">Transfer</a>
CSRF tokens in URLs get logged in server access logs, browser history, and the Referer header sent to other sites. Always transmit tokens in request bodies or headers.
CheckVibe's automated security scanner checks your web application for CSRF vulnerabilities as part of its 36-point security audit. Specifically, the CSRF scanner:
<form> elements with method="POST" and checks whether they include a hidden CSRF token field.SameSite attribute set and flags cookies with SameSite=None that could be vulnerable.Origin header or require a CSRF token.Content-Type validation and CORS configuration.Run a free scan at checkvibe.dev to check your application for CSRF vulnerabilities and 35 other security issues.
Yes. While SameSite cookies have reduced the attack surface significantly, CSRF is not gone. Applications with misconfigured cookies, GET endpoints that mutate state, subdomain vulnerabilities, or older browser support requirements remain at risk. OWASP still includes broken access control (which encompasses CSRF) in its Top 10.
You should still implement at least one additional layer. SameSite Lax does not protect GET-based state changes, does not block same-site attacks from subdomains, and is not enforced by all clients (older browsers, some mobile webviews, automated tools). Defense in depth means combining SameSite with Origin header validation or CSRF tokens.
It depends on how clients authenticate. If your API uses cookie-based sessions (common in web apps where the frontend and API share a domain), yes — you need CSRF protection. If your API uses Authorization: Bearer <token> headers exclusively (common in mobile apps and third-party integrations), CSRF is not a concern because the browser does not automatically attach the token. Check our API security checklist for a complete guide.
Next.js Server Actions (introduced in the App Router) have built-in CSRF protection that verifies the Origin header matches the Host header. This happens automatically — no configuration needed. However, Next.js API Route Handlers (app/api/) do not have automatic CSRF protection. You need to implement Origin validation yourself, either in middleware or in each route handler. See our Next.js security best practices for more details.
CSRF forces a user's browser to perform an unwanted action on a site where they are authenticated. The attacker cannot read the response. XSS injects malicious scripts into a page that execute in the user's browser context, allowing the attacker to steal data, session tokens, and perform any action the user can. XSS can be used to bypass CSRF protections because the injected script runs in the same origin and can read CSRF tokens. Fix XSS first — it is the more dangerous vulnerability. See our XSS vulnerability guide for prevention techniques.
CSRF protection is a fundamental part of web application security. The best defense combines multiple layers: SameSite cookies as the baseline, Origin header validation as the primary check, and CSRF tokens for critical operations. No single method is bulletproof, but together they make cross-site request forgery effectively impossible.
Scan your site with CheckVibe to check for CSRF vulnerabilities, missing security headers, and 34 other security issues — free for your first scan.
Paste your URL and get a security report in 30 seconds. 100+ automated checks with AI-powered fix prompts.
Scan your site freeRelated articles
The 7 most dangerous JWT security mistakes developers make. Algorithm confusion, weak secrets, missing expiration, and more — with code examples showing how to fix each one.
Next.js apps are fast to build but easy to misconfigure. Here are 10 specific security issues most developers miss, with code examples for each vulnerability and its fix.
The OWASP Top 10 explained without the enterprise jargon. Practical examples from Next.js, Supabase, and React apps that indie hackers actually build.