What is CSRF and how to prevent it?
CSRF (Cross-Site Request Forgery) is an attack where a malicious site forces a user's browser to send authenticated requests to another site without the user knowing.
Theory
TL;DR
- Browsers send cookies automatically to any site that set them, regardless of where the request originates
- Analogy: a butler who hands your house keys to anyone who asks, because they already know your address
- The server sees valid session cookies and has no way to tell if the request came from your form or an attacker's page on
evil.com - Fix: attach a unique secret token to every state-changing request that attackers cannot read or guess cross-site
SameSite=Stricton the session cookie blocks most modern CSRF attacks without any extra code
Quick Example
// Vulnerable: no CSRF check - attacker on evil.com can forge this POST
app.post('/transfer', (req, res) => {
const { to, amount } = req.body;
// req.session.user is set via cookie - browser sends it automatically
transferMoney(req.session.user.id, to, amount); // runs for forged requests too
res.send('Transfer complete');
});
// Protected: csurf middleware blocks requests without a valid token
const csurf = require('csurf');
app.use(csurf({ cookie: true }));
app.post('/transfer', (req, res) => {
// Missing or wrong _csrf token -> 403 Forbidden, request never reaches here
transferMoney(req.session.user.id, req.body.to, req.body.amount);
res.send('Transfer complete');
});The difference is one middleware line. Without it, any page on the internet can POST to your endpoint and ride along with the user's session cookie.
How the Attack Works
Browsers follow the Same-Origin Policy for reading data, but cookies are exempt from it. When a browser visits evil.com and that page contains <img src="https://bank.com/transfer?to=attacker&amount=1000">, the browser fires that GET request and attaches the bank.com session cookie automatically. The server sees a valid session and processes it.
POST attacks are slightly more involved but equally effective:
<!-- On evil.com -->
<form id="f" action="https://bank.com/transfer" method="POST">
<input type="hidden" name="to" value="attacker" />
<input type="hidden" name="amount" value="1000" />
</form>
<script>document.getElementById('f').submit();</script>
<!-- User sees nothing. Transfer goes through. -->The user opens the page, the form submits in the background, and the money moves. The server has no signal that the form came from evil.com rather than from bank.com.
When to Apply CSRF Protection
- State-changing operations (POST, PUT, DELETE) on authenticated endpoints: always require a token
- JSON APIs:
Content-Type: application/jsonalone does not protect you (see common mistakes below) - Read-only GET endpoints: no CSRF protection needed, but make sure they really are read-only
- Single-page apps: combine
SameSite=Strictcookies with a custom header likeX-CSRF-Token - Microservices with JWT: store the token in
Authorizationheader, not a cookie, and the CSRF surface disappears entirely
Prevention Methods
CSRF tokens are the most widely used approach. The server generates a random value tied to the session and embeds it in every form. On submission it checks whether the value matches. An attacker on evil.com cannot read this value because of the Same-Origin Policy.
// Express + csurf (csurf v1.11.0)
const csurf = require('csurf');
app.use(csurf({ cookie: true }));
// Expose token for React SPA
app.get('/csrf', (req, res) => res.json({ token: req.csrfToken() }));
// React form that fetches the token on mount
function ProfileForm() {
const [csrfToken, setCsrfToken] = useState('');
useEffect(() => {
fetch('/csrf').then(r => r.json()).then(d => setCsrfToken(d.token));
}, []);
return (
<form action="/profile" method="POST">
<input type="hidden" name="_csrf" value={csrfToken} />
<input name="email" />
<button>Update</button>
</form>
);
// Forged POST from evil.com -> 403, token is missing
}SameSite cookies are the modern complement. Set sameSite: 'strict' on your session cookie and the browser will not send it on cross-site requests at all.
res.cookie('sessionId', id, {
httpOnly: true,
secure: true,
sameSite: 'strict'
});Strict blocks all cross-site cookie sending. Lax allows cookies on top-level GET navigation (a user clicking a link) but blocks cross-site POST. None sends cookies everywhere and requires Secure.
Teams I've worked with often stop at SameSite and skip CSRF tokens. On modern Chrome that mostly holds. But Apple did not fully fix their SameSite implementation until Safari 16.4, and a token-based check costs about ten minutes to add. Using both together is the safe default.
Double-submit cookie is useful when you have no server-side session store. Generate a random token, put it in a cookie, and also require it in a request header or form field. The server checks they match. An attacker cannot read your cookie from another origin, so they cannot supply both values at once.
Common Mistakes
Protecting forms but ignoring AJAX endpoints. fetch() with Content-Type: application/json does not automatically block CSRF. Browsers permit cross-site JSON POSTs in some configurations.
// Attacker on evil.com
fetch('https://bank.com/api/transfer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to: 'attacker', amount: 1000 })
});
// If the server accepts JSON without checking X-CSRF-Token - this succeedsFix: require X-CSRF-Token in headers for all API routes, not just HTML form submissions.
Reusing static tokens. A token is only useful if it is unpredictable and tied to the session. A hardcoded string like 'static-token-123' can be stolen via XSS or brute-forced. Use req.csrfToken(), which generates a new value per call and binds it to the session.
Using GET for state changes. Any <img> tag can fire a GET request cross-site without any user interaction.
// Wrong: state change via GET
app.get('/user/delete', (req, res) => deleteUser(req.session.userId));
// Correct: POST with CSRF token
app.post('/user/delete', csrfProtection, (req, res) => deleteUser(req.session.userId));Trusting Origin/Referer headers as the sole defense. Proxies and privacy tools strip these headers. Some browsers do not send them. OWASP lists this as unreliable on its own. Use it as a secondary check layered on top of tokens, not as the primary one.
Setting SameSite=Lax everywhere and assuming you are safe. Lax still allows cookies on top-level POST navigation, which means a malicious link can trigger CSRF in some configurations.
Real-World Usage
- Express.js:
csurfmiddleware, used in KeystoneJS and 1M+ npm projects - Django:
@csrf_protectdecorator, enabled globally by default (powers Instagram's admin) - Rails:
protect_from_forgery, built in (GitHub, Shopify use it) - Spring Boot:
@EnableWebSecuritywithCsrfTokenRepository - Next.js:
next-csrffor API routes in Vercel templates
For microservices with JWT: store the token in the Authorization header. Browsers do not attach custom headers to cross-site requests automatically, so the CSRF attack surface does not exist.
Follow-up Questions
Q: What is the difference between CSRF and XSS?
A: XSS injects malicious code into your site and can steal tokens directly. CSRF uses the user's existing authentication from another origin without injecting anything. XSS breaks the Same-Origin Policy. CSRF exploits the cookie exception to it.
Q: How does SameSite=Strict prevent CSRF?
A: The browser does not send the session cookie on any cross-site request, including forged POST forms. Without the cookie the server sees an unauthenticated request and rejects it. Chrome added SameSite=Lax as the default in version 80.
Q: What is the double-submit cookie technique?
A: Generate a random token, put it in a cookie, and also require it in a request header or form field. The server checks both values match. An attacker cannot read your cookie from another origin, so they cannot provide both at the same time.
Q: Why not just verify Origin and Referer headers?
A: Proxies and privacy tools strip these headers, and some browsers do not send them. OWASP marks this as an unreliable standalone defense. Use it as a secondary layer on top of token validation.
Q: In a microservices setup with JWT, how do you handle CSRF at the API gateway?
A: Store the JWT in the Authorization header, not a cookie. Browsers do not automatically attach custom headers to cross-site requests, so there is no CSRF vector. If you must use cookies for the JWT, add a signed X-CSRF-Token header and validate it per service to close the gap.
Examples
Basic: What the Attack Looks Like
<!-- evil.com - silent auto-submit on page load -->
<form id="f" action="https://bank.com/transfer" method="POST">
<input type="hidden" name="to" value="attacker@evil.com" />
<input type="hidden" name="amount" value="5000" />
</form>
<script>document.getElementById('f').submit();</script>
<!-- User sees a blank page for half a second. Transfer is already done. -->If bank.com does not validate a CSRF token, the transfer completes. The user's browser did exactly what it was instructed to do, with full authentication attached.
Intermediate: Token-Protected Form in React
// Client: fetches CSRF token from the server before rendering
function TransferForm() {
const [token, setToken] = useState('');
const [to, setTo] = useState('');
const [amount, setAmount] = useState('');
useEffect(() => {
fetch('/csrf').then(r => r.json()).then(d => setToken(d.token));
}, []);
const handleSubmit = async (e) => {
e.preventDefault();
await fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': token // attacker on evil.com cannot read this value
},
body: JSON.stringify({ to, amount })
});
};
return (
<form onSubmit={handleSubmit}>
<input value={to} onChange={e => setTo(e.target.value)} placeholder="Recipient" />
<input value={amount} onChange={e => setAmount(e.target.value)} placeholder="Amount" />
<button type="submit">Transfer</button>
</form>
);
}// Server: exposes token endpoint, validates on every POST
app.get('/csrf', csrfProtection, (req, res) => res.json({ token: req.csrfToken() }));
app.post('/api/transfer', csrfProtection, (req, res) => {
transferMoney(req.session.user.id, req.body.to, req.body.amount);
res.json({ status: 'ok' });
// Returns 403 if X-CSRF-Token is absent or does not match the session token
});Advanced: Three-Layer Protection in Express
const express = require('express');
const csurf = require('csurf');
const cookieParser = require('cookie-parser');
const app = express();
app.use(express.json());
app.use(cookieParser());
// Layer 1: SameSite session cookie - blocks most browser-based forgeries
app.use((req, res, next) => {
res.cookie('sessionId', req.sessionID, {
httpOnly: true,
secure: true,
sameSite: 'strict'
});
next();
});
// Layer 2: CSRF token middleware - catches what SameSite misses
const csrfProtection = csurf({ cookie: true });
// Layer 3: Origin check as a secondary guard
const checkOrigin = (req, res, next) => {
const origin = req.headers.origin;
if (origin && !['https://myapp.com'].includes(origin)) {
return res.status(403).json({ error: 'Origin not allowed' });
}
next();
};
// All three layers active on the sensitive route
app.post('/api/transfer', checkOrigin, csrfProtection, (req, res) => {
transferMoney(req.session.user.id, req.body.to, req.body.amount);
res.json({ status: 'ok' });
});
// Token endpoint for the SPA
app.get('/csrf', csrfProtection, (req, res) => {
res.json({ token: req.csrfToken() });
});To get past this setup an attacker would need to bypass SameSite restrictions, guess the CSRF token, and spoof the Origin header simultaneously. Getting past the first two alone is not realistic.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.