Skip to main content

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=Strict on the session cookie blocks most modern CSRF attacks without any extra code

Quick Example

javascript
// 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:

html
<!-- 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/json alone 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=Strict cookies with a custom header like X-CSRF-Token
  • Microservices with JWT: store the token in Authorization header, 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.

javascript
// 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.

javascript
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.

javascript
// 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 succeeds

Fix: 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.

javascript
// 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: csurf middleware, used in KeystoneJS and 1M+ npm projects
  • Django: @csrf_protect decorator, enabled globally by default (powers Instagram's admin)
  • Rails: protect_from_forgery, built in (GitHub, Shopify use it)
  • Spring Boot: @EnableWebSecurity with CsrfTokenRepository
  • Next.js: next-csrf for 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

html
<!-- 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

jsx
// 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> ); }
javascript
// 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

javascript
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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?