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.
What Is CSRF?
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.
A Real Attack Scenario
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.
How CSRF Attacks Work
The attack follows a predictable pattern:
- Victim authenticates — The user logs into your application. The server sets a session cookie.
- Victim visits attacker's site — Through a phishing email, a malicious ad, or a compromised website, the user lands on a page the attacker controls.
- Attacker's page sends a request — A hidden form, an image tag, or JavaScript on the attacker's page sends a request to your application.
- Browser attaches cookies — The browser automatically includes all cookies for your domain, including the session cookie.
- Server processes the request — Your server sees valid authentication credentials and executes the action — transferring money, changing an email address, deleting data.
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.
5 CSRF Protection Methods
1. CSRF Tokens (Synchronizer Pattern)
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.
2. SameSite Cookie Attribute
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 tobank.comfrom 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 viaPOSTforms 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. RequiresSecureflag. 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.
3. Custom Request Headers
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.
4. Checking Origin and Referer Headers
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.
5. Double Submit Cookie Pattern
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.
Framework-Specific Implementation
Next.js
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*',
};
Express
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
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
Common CSRF Mistakes
1. Relying Only on SameSite Cookies
SameSite Lax is the default in modern browsers and blocks most CSRF attacks. But it is not a complete solution:
- GET requests still send cookies. If your app has a GET endpoint that changes state (
/api/delete?id=123),Laxwill not protect it. - Same-site attacks are not blocked. If an attacker controls any subdomain (
evil.yoursite.com), SameSite does not help becauseLaxconsiders subdomains as "same-site." - Older browsers do not enforce SameSite defaults.
Always pair SameSite with at least one other method.
2. GET Endpoints That Mutate State
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.
3. CSRF Bypass via Subdomain
If an attacker exploits an XSS vulnerability on any subdomain of your application, they can:
- Set cookies for your main domain (cookie tossing)
- Make requests that are considered "same-site"
- Override double-submit CSRF cookies
This is why CSRF tokens stored in server-side sessions are more secure than double-submit cookies for high-security applications.
4. Forgetting Logout CSRF
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.
5. Token Leakage in URLs
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.
How CheckVibe Tests for CSRF
CheckVibe's automated security scanner checks your web application for CSRF vulnerabilities as part of its 36-point security audit. Specifically, the CSRF scanner:
- Detects forms without CSRF tokens — Scans HTML responses for
<form>elements withmethod="POST"and checks whether they include a hidden CSRF token field. - Checks cookie configuration — Verifies that session cookies have the
SameSiteattribute set and flags cookies withSameSite=Nonethat could be vulnerable. - Tests state-changing endpoints — Identifies API endpoints that accept POST, PUT, or DELETE requests and verifies that they validate the
Originheader or require a CSRF token. - Inspects security headers — Checks for proper security headers that complement CSRF protection, including
Content-Typevalidation and CORS configuration.
Run a free scan at checkvibe.dev to check your application for CSRF vulnerabilities and 35 other security issues.
FAQ
Is CSRF still relevant in 2026?
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.
Do I need CSRF protection if I use SameSite cookies?
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.
Does my REST API need CSRF protection?
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.
How does Next.js handle CSRF?
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.
What is the difference between CSRF and XSS?
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.