CSRF: What It Is and How to Defend Your Web Applications
Cross-Site Request Forgery (CSRF) is an attack that tricks the victim's browser into making unwanted HTTP requests to an application the user is authenticated on. The victim notices nothing. The server can't tell the legitimate request apart from the "forged" one.
Despite being an old, well-documented vulnerability, it keeps showing up in the OWASP Top 10 and in bug bounty reports. It's easy to underestimate because it doesn't require stealing credentials, doesn't need XSS, and leaves no obvious trace in the application's logs.
Contents
- How CSRF works
- A practical attack example
- Types of CSRF
- How to defend against it
- Implementation in PHP, Django and REST APIs
- Common mistakes
- Testing
How CSRF works
The browser automatically attaches session cookies to every request to a domain, regardless of where that request originates. This behavior, designed to make browsing smoother, is exactly what CSRF exploits.
In a classic scenario, the user logs into vulnerablesite.com, and the browser saves the session cookie. Without logging out, the user visits a malicious page on another domain. That page contains code that makes a request to vulnerablesite.com. The browser attaches the session cookie, vulnerablesite.com receives an authenticated request, and executes it.
From the server's point of view, everything looks normal. It has no way to tell this request apart from one originating from the legitimate interface.
For the attack to work, a few conditions need to be met: the victim must be authenticated at the time of the attack, the application must use session cookies, the target operation must be executable with a single predictable request, and no defense mechanisms must be active.
A practical attack example
Suppose vulnerablesite.com has an endpoint for wire transfers:
POST /transfer HTTP/1.1
Host: vulnerablesite.com
Cookie: session=abc123
amount=500&iban=IT60X0542811101000000123456&reason=Rent
An attacker who knows this structure creates an HTML file:
<html>
<body onload="document.forms[0].submit()">
<form action="https://vulnerablesite.com/transfer" method="POST" style="display:none">
<input type="hidden" name="amount" value="500">
<input type="hidden" name="iban" value="IT60X0542811101000000654321">
<input type="hidden" name="reason" value="Gift">
</form>
</body>
</html>
When the victim opens this link (via email, a message, or a redirect from a compromised legitimate site), the form submits itself automatically with the session cookie attached. The transfer goes through without the victim explicitly clicking anything.
Variant using an img tag
For GET requests on poorly designed applications, an invisible image tag is enough:
<img src="https://target.com/admin/delete-user?id=42" width="0" height="0">
The browser attempts to load the image, executing the GET request with cookies attached.
Types of CSRF
Classic form-based CSRF is the most common. HTML forms can send cross-origin requests without CORS restrictions, because CORS applies to JavaScript responses, not to form submissions. This opens the door on any application that doesn't implement CSRF tokens.
Login CSRF: the attacker forges a login request using their own credentials, so the victim ends up authenticated into the attacker's account. Anything the user enters afterward (addresses, card numbers, personal data) goes to the attacker.
Stored CSRF: the malicious payload is saved inside the application itself, for example in a profile field or a comment, and triggers when another user views that page. If the viewer is an administrator, the consequences are far more serious than a one-shot attack.
How to defend against it
CSRF token (Synchronizer Token Pattern)
The server generates a random token for each session, includes it in the form as a hidden field, and verifies it's present and correct when the request comes in. If it doesn't match, the request is rejected with a 403.
The attacker can't read the token from the legitimate page because of the Same-Origin Policy, so they can't include it in the "forged" request.
Minimum requirements for this mechanism to work: the token must be generated with a CSPRNG, have at least 128 bits of entropy (32 hex characters), be tied to the session, and be invalidated on logout. A weak or predictable token makes the whole mechanism useless.
SameSite cookies
A cookie attribute that instructs the browser not to attach it to cross-site requests. Supported by all modern browsers.
Set-Cookie: session=abc123; SameSite=Strict; Secure; HttpOnly
SameSite=Strict blocks the cookie on any cross-site request, including navigation from external links. SameSite=Lax (the Chrome default since 2020) only sends it for top-level GET navigation. SameSite=None removes any protection and should only be used when strictly necessary (e.g. third-party widgets that require authentication).
SameSite should be used alongside CSRF tokens, not instead of them. There are edge cases (old browsers, particular proxy configurations) where SameSite alone isn't enough.
Checking the Origin header
The server checks the request's Origin header and rejects it if it doesn't come from the expected domain:
def verify_origin(request):
origin = request.headers.get('Origin') or request.headers.get('Referer', '')
if not origin.startswith('https://my-app.com'):
raise PermissionError("Unauthorized origin")
Some browsers and proxies strip the Referer header, so this check alone isn't sufficient.
Double Submit Cookie
Useful for stateless APIs. The server generates a random token, sends it both as a cookie and in the request body/header, and checks that the two match. The attacker can't read the cookie's value from another domain because of the Same-Origin Policy, so they can't build a valid request.
Implementation in PHP, Django and REST APIs
Native PHP
// Token generation
session_start();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$csrf_token = $_SESSION['csrf_token'];
<form action="/transfer" method="POST">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<!-- other fields -->
<button type="submit">Confirm</button>
</form>
// Validation on receipt
session_start();
function validate_csrf(string $received_token): void {
if (
empty($_SESSION['csrf_token']) ||
!hash_equals($_SESSION['csrf_token'], $received_token)
) {
http_response_code(403);
die('Invalid request');
}
}
validate_csrf($_POST['csrf_token'] ?? '');
hash_equals() instead of === prevents timing attacks, where an attacker could infer the token's length by measuring the comparison's response times.
Django
Django has CSRF protection enabled by default. Don't disable it.
# settings.py - middleware already included by default
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
# ...
]
<!-- In every POST form -->
<form method="post" action="/transfer/">
{% csrf_token %}
<!-- other fields -->
</form>
For fetch/axios calls from the JavaScript frontend:
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: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken'),
},
body: JSON.stringify({ amount: 500, iban: '...' })
});
REST APIs with JWT
Stateless APIs that use JWTs in the Authorization header don't suffer from classic CSRF: the browser doesn't automatically attach custom headers the way it attaches cookies. The problem arises when the JWT is saved in a cookie for convenience, a fairly common pattern. In that case the application is exposed again, and needs SameSite=Strict on the cookie or the Double Submit Cookie pattern.
// Safe pattern: JWT in the header, not in a cookie
fetch('/api/transfer', {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('jwt')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload)
});
Common mistakes
Using GET for operations with side effects. GET requests must be idempotent. Any operation that changes application state (deletions, updates, transfers) must go through POST, PUT, or DELETE.
// Bad
// /admin/delete-user?id=42
if (isset($_GET['id'])) {
$db->deleteUser($_GET['id']);
}
A poorly generated token. A token derived from predictable data like the session ID defeats the whole mechanism:
$token = md5(session_id()); // Bad, predictable
$token = bin2hex(random_bytes(32)); // Good, 256 bits of random entropy
@csrf_exempt used carelessly. In Django (and the equivalents in other frameworks), this decorator should only be used on endpoints that receive requests from external, non-browser systems, such as webhooks or server-to-server integrations. Never on endpoints reachable by authenticated users via a browser.
Testing
Manual testing with Burp Suite: intercept an authenticated POST request, remove the CSRF token from the body, replay the request. If the server responds 200, the endpoint is vulnerable. Burp also has Engagement Tools > Generate CSRF PoC, which automatically builds a test HTML file from the intercepted request.
With OWASP ZAP: the active scanner analyzes the application's forms and flags the ones missing tokens. Useful for a systematic check across applications with many endpoints.
Comparing mechanisms
| Mechanism | Effectiveness | When to use it |
|---|---|---|
| CSRF token (Synchronizer) | High | Applications with server-side sessions |
| SameSite=Strict/Lax | High | Always, as an extra layer |
| Double Submit Cookie | Medium-High | Stateless APIs with cookies |
| Origin/Referer check | Medium | As a complement |
| JWT in Authorization header | High | REST APIs without session cookies |