How CSRF Works
Cross-Site Request Forgery exploits the browser’s automatic inclusion of cookies in cross-origin requests. When a user is authenticated to bank.com and visits a malicious page on evil.com, any request that evil.com causes the browser to make to bank.com will automatically carry the victim’s bank.com session cookie.
A minimal CSRF attack:
<!-- On evil.com -->
<img src="https://bank.com/transfer?to=attacker&amount=10000" width="0" height="0">
The browser fetches the URL, sends the victim’s session cookie, and the transfer executes — no JavaScript required.
For POST requests:
<form id="csrf" action="https://bank.com/transfer" method="POST">
<input name="to" value="attacker">
<input name="amount" value="10000">
</form>
<script>document.getElementById('csrf').submit();</script>
The attack works because the server cannot distinguish between a legitimate form submission and one forged by a third-party site — both carry the same session cookie.
Why Checking the Referer Header Fails
A common but insufficient defence: check that the Referer header matches your domain. Problems:
Refereris not sent in all browsers and configurations (HTTPS→HTTP transitions, privacy settings)Referercan be stripped by intermediaries- The
referrer-policyheader on the attacker’s page can suppress or control the Referer value
Never rely on Referer alone.
The Three Defences That Work
1. Synchronizer Token Pattern (CSRF Tokens)
Generate a random, unpredictable token server-side, include it in every state-changing form as a hidden field, and validate it on submission. Since evil.com cannot read your server’s responses (same-origin policy), it cannot obtain the token to include in a forged request.
Django (Python) — built-in:
# settings.py — CsrfViewMiddleware is included by default
MIDDLEWARE = [
...
'django.middleware.csrf.CsrfViewMiddleware',
...
]
<!-- Template -->
<form method="POST">
{% csrf_token %}
<input type="text" name="amount">
<button type="submit">Transfer</button>
</form>
For AJAX:
// Get the token from the cookie
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
}
fetch('/api/transfer', {
method: 'POST',
headers: {
'X-CSRFToken': getCookie('csrftoken'),
'Content-Type': 'application/json',
},
body: JSON.stringify({ to: 'friend', amount: 50 }),
});
Express.js (Node) with csurf:
const csrf = require('csurf');
const csrfProtection = csrf({ cookie: true });
app.get('/transfer', csrfProtection, (req, res) => {
res.render('transfer', { csrfToken: req.csrfToken() });
});
app.post('/transfer', csrfProtection, (req, res) => {
// If CSRF token is invalid, csurf throws an error
processTransfer(req.body);
res.redirect('/success');
});
Spring Boot (Java):
// Spring Security includes CSRF protection by default for stateful apps
// For REST APIs with JWT, you typically disable it and use stateless auth instead:
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
);
return http.build();
}
}
2. SameSite Cookie Attribute
The SameSite attribute on session cookies directly prevents CSRF by instructing the browser not to send the cookie on cross-site requests.
# Django settings.py
SESSION_COOKIE_SAMESITE = 'Lax' # or 'Strict'
CSRF_COOKIE_SAMESITE = 'Lax'
// Express.js
app.use(session({
secret: process.env.SESSION_SECRET,
cookie: {
sameSite: 'lax',
secure: true,
httpOnly: true,
}
}));
Strict vs Lax — which to use:
Strict— cookie never sent on cross-site requests, including top-level navigations (clicking a link from another site logs the user out). Best security, but breaks login flows from external links.Lax— cookie sent on top-level navigations (GET requests from links) but not on cross-origin subresource requests or form POSTs. The recommended default — prevents CSRF attacks while maintaining normal navigation.None— always sent cross-site (requiresSecure). Use only for third-party cookie scenarios (embeds, OAuth redirects).
Important caveat: SameSite=Lax does not protect against CSRF if your application performs state changes via GET requests. Ensure all state-changing operations use POST/PUT/DELETE.
3. Custom Request Header Validation
For API endpoints that receive JSON via fetch() or XMLHttpRequest, requiring a custom header (e.g., X-Requested-With: XMLHttpRequest) is a lightweight defence. Cross-origin requests cannot set arbitrary custom headers without a CORS preflight, and the preflight would be blocked by your CORS policy.
// Client
fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify(data),
});
# Server-side validation (Flask example)
@app.route('/api/transfer', methods=['POST'])
def transfer():
if request.headers.get('X-Requested-With') != 'XMLHttpRequest':
abort(403)
# process request
This is a defence-in-depth measure, not a standalone protection — pair it with SameSite cookies.
What About Single-Page Applications?
SPAs that use JWTs stored in memory (not cookies) are naturally immune to CSRF — there’s no cookie for the browser to automatically attach. However:
- If you store the JWT in
localStorage, XSS can steal it (different threat model) - If you use
httpOnlycookies for the JWT refresh token, CSRF is relevant again for the refresh endpoint
The recommended pattern for SPAs: store access tokens in memory, use httpOnly + SameSite=Strict cookies for refresh tokens, and validate the Origin or Referer header on the token refresh endpoint.
Testing for CSRF
Quick manual test with Burp Suite:
- Capture a state-changing request
- Right-click → “Generate CSRF PoC”
- Open the PoC in a browser while authenticated to the target — if the request succeeds, CSRF is present
Automated scanning: OWASP ZAP’s active scanner includes CSRF detection. Run against your staging environment as part of CI.