Cross-Site Request Forgery (CSRF) Prevention: A Practical Guide to Protecting Your Users
Picture this: You're logged into your bank account in one browser tab, casually browsing a forum in another. You click what looks like a harmless link to a funny cat video, and suddenly $10,000 has been transferred out of your account. No malware. No phishing. No stolen password. Welcome to the world of Cross-Site Request Forgery.
CSRF (sometimes pronounced "sea-surf") is one of those attacks that sounds almost too simple to be dangerous. And yet, it has been responsible for some of the most devastating security breaches in web history. The good news? Once you understand how it works, defending against it becomes remarkably straightforward.
Understanding the Attack: How CSRF Actually Works
At its core, CSRF exploits a fundamental behavior of web browsers: they automatically include cookies with every request to a domain, regardless of where that request originates. This is the same mechanism that keeps you logged in as you navigate between pages. It's convenient. It's also exploitable.
Here's the anatomy of a CSRF attack:
- The victim is authenticated to a target application (your banking site, for example)
- The attacker crafts a malicious request that performs some action on that application
- The victim is tricked into triggering that request (clicking a link, loading an image, visiting a page)
- The browser dutifully sends the victim's authentication cookies along with the malicious request
- The target application sees a legitimate-looking authenticated request and executes it
The key insight is that the attacker doesn't need to steal your credentials. They just need to trick your browser into making a request while you're already authenticated.
Attack Scenarios: From Theory to Reality
Let's make this concrete with some examples that will help you recognize vulnerable patterns in your own applications.
Scenario 1: The GET Request Trap
Imagine a poorly designed banking application where money transfers happen via GET requests:
https://bank.example.com/transfer?to=attacker&amount=10000
An attacker could embed this in an image tag on any website:
<img src="https://bank.example.com/transfer?to=attacker&amount=10000" style="display:none">
The moment you visit a page containing this invisible image, your browser attempts to load it, sending your bank's session cookie along for the ride. Transfer complete.
This is why sensitive actions should never use GET requests—but that alone isn't enough.
Scenario 2: The Hidden Form Attack
Smart developers know to use POST for sensitive operations. But without proper CSRF protection, POST requests are just as vulnerable:
<form action="https://bank.example.com/transfer" method="POST" id="evil-form"> <input type="hidden" name="to" value="attacker"> <input type="hidden" name="amount" value="10000"> </form> <script>document.getElementById('evil-form').submit();</script>
This form automatically submits when the page loads. The user sees nothing but a brief flash before being redirected. Their session cookie is included, and the transfer goes through.
Scenario 3: The JSON API Pitfall
"But wait," I hear you say, "my API only accepts JSON! Surely that's safe?"
Not necessarily. While you can't send JSON directly from a simple form, attackers have gotten creative. If your API doesn't validate the Content-Type header strictly, they might send form data that happens to be valid JSON:
<form action="https://api.example.com/transfer" method="POST" enctype="text/plain"> <input name='{"to":"attacker","amount":10000,"ignore":"' value='"}'> </form>
This sends a body that looks like: {"to":"attacker","amount":10000,"ignore":"="}
If your parser is lenient, the attack succeeds.
Defense Strategy 1: CSRF Tokens
The most robust defense against CSRF is the synchronizer token pattern. Here's how it works:
- Generate a unique, unpredictable token for each user session (or even each request)
- Include this token in every form or as a custom header for AJAX requests
- Validate the token on the server for every state-changing request
The beauty of this approach is that an attacker cannot know the token value. It's not in the URL, it's not in a cookie that gets sent automatically—it's embedded in the page that only the legitimate user can see.
Implementation Example
When rendering a form:
<form action="/transfer" method="POST"> <input type="hidden" name="csrf_token" value="a8f5d3e7b1c9..."> <input type="text" name="to" placeholder="Recipient"> <input type="number" name="amount" placeholder="Amount"> <button type="submit">Transfer</button> </form>
On the server (pseudocode):
def handle_transfer(request): expected_token = get_token_from_session(request.session) received_token = request.form.get('csrf_token') if not constant_time_compare(expected_token, received_token): raise SecurityError("CSRF token validation failed") # Proceed with the transfer
Note the use of constant_time_compare—this prevents timing attacks that could allow an attacker to guess the token character by character.
Token Generation Best Practices
- Use a cryptographically secure random number generator
- Tokens should be at least 128 bits of entropy (32 hex characters)
- Tie tokens to the user session
- Consider per-request tokens for highly sensitive operations
Defense Strategy 2: SameSite Cookies
The SameSite cookie attribute is a more recent addition to our defensive arsenal, and it's elegantly simple. It tells the browser when to include cookies with cross-site requests.
Three modes are available:
Strict: Cookies are never sent with cross-site requests. Period.Lax: Cookies are sent with "safe" top-level navigations (clicking a link) but not with forms or AJAXNone: The old behavior—cookies always sent (requiresSecureflag)
Set-Cookie: session=abc123; SameSite=Lax; Secure; HttpOnly
The Case for Lax as Default
Modern browsers now default to SameSite=Lax when no attribute is specified. This is a huge win for security. It means:
- Form submissions from external sites won't include your session cookie
- AJAX requests from external sites won't include your session cookie
- But users clicking links from emails or other sites will still arrive logged in
For most applications, Lax provides excellent protection with minimal friction. However, Strict might break legitimate use cases like clicking a link to your site from an email and finding yourself logged out.
Important Caveat
SameSite cookies are an excellent defense-in-depth measure, but they shouldn't be your only protection. Browser support, while now excellent, wasn't always universal. And there are edge cases involving subdomains and certain redirect scenarios. Always combine SameSite with token-based protection.
Defense Strategy 3: Double-Submit Cookie Pattern
This pattern is useful when you can't store server-side session state (think stateless microservices or CDN-served content). Here's how it works:
- Set a random value in a cookie (accessible to JavaScript)
- Include that same value in a request header or form field
- Verify that both values match
// Client-side const csrfToken = getCookie('csrf_token'); fetch('/api/transfer', { method: 'POST', headers: { 'X-CSRF-Token': csrfToken, 'Content-Type': 'application/json' }, body: JSON.stringify({ to: 'recipient', amount: 100 }) });
Why does this work? An attacker can cause your browser to send the cookie, but they cannot read its value (same-origin policy) to include it in a custom header or form field.
Strengthening Double-Submit
The basic pattern has a weakness: if an attacker can set cookies (via a subdomain they control or an XSS vulnerability elsewhere), they could potentially set both values. To mitigate this:
- Sign the cookie value with a server-side secret
- Include the session ID in the signed value
- Verify the signature on every request
Framework Protections: Standing on the Shoulders of Giants
Most modern web frameworks have built-in CSRF protection. Use it. Seriously. These implementations have been battle-tested by millions of applications.
Django
CSRF protection is enabled by default. Just include {% csrf_token %} in your forms:
<form method="POST"> {% csrf_token %} <!-- form fields --> </form>
For AJAX, include the token in the X-CSRFToken header.
Rails
Rails includes CSRF protection by default in ApplicationController:
class ApplicationController < ActionController::Base protect_from_forgery with: :exception end
The token is automatically included in forms created with Rails helpers.
Express.js
Use the csurf middleware (or its maintained successors):
const csrf = require('csurf'); app.use(csrf({ cookie: true })); app.get('/form', (req, res) => { res.render('form', { csrfToken: req.csrfToken() }); });
Spring Security
CSRF protection is enabled by default:
@EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http.csrf(); // Enabled by default, this just makes it explicit } }
Common Pitfalls and How to Avoid Them
Even with these defenses, I've seen teams stumble. Here are the most common mistakes:
Pitfall 1: Forgetting API Endpoints
Your web forms are protected, but what about your REST API? If mobile apps or SPAs use the same session cookies, they need CSRF protection too. Use custom headers that JavaScript must set explicitly.
Pitfall 2: Token Leakage
Don't put CSRF tokens in URLs or log them. If an attacker can see a token (through Referer headers, browser history, or logs), your protection evaporates.
Pitfall 3: GET Requests That Modify State
This should be a cardinal sin, but it still happens. GET requests should be idempotent. If clicking a link can delete data or transfer money, you've already lost.
Pitfall 4: Skipping Protection for "Internal" Endpoints
"This endpoint is only called by our own frontend" isn't a security boundary. If it's reachable from a browser with cookies, it needs protection.
Pitfall 5: Weak Token Validation
Comparing tokens with simple string equality can be vulnerable to timing attacks. Always use constant-time comparison functions.
Testing Your CSRF Defenses
Before you ship, verify your protections work:
- Manual testing: Try submitting forms without the token or with a modified token
- Browser dev tools: Confirm SameSite attributes are set correctly on session cookies
- Automated scanning: Tools like OWASP ZAP can identify missing CSRF protection
- Cross-origin testing: Actually try the attack from a different origin
Wrapping Up: Defense in Depth
CSRF is a trust problem. Browsers trust that requests with your cookies come from you. CSRF tokens prove that a request actually originated from your own page. SameSite cookies prevent cookies from being sent in suspicious contexts. Together, they create a robust defense.
My recommended approach for new applications:
- Set
SameSite=Laxon all session cookies (orStrictif your UX can handle it) - Use your framework's CSRF protection for all state-changing requests
- Require custom headers for API endpoints to leverage CORS protection
- Never use GET for state-changing operations
- Audit regularly to catch new endpoints that might have slipped through
CSRF has been haunting web applications for decades, but we have the tools to stop it. The attacks I described earlier? They should bounce harmlessly off any application that follows these practices. Your users are trusting you with their authenticated sessions. Don't let them down.
Now go audit your applications. I'll wait.
