← Back to Blog

Cross-Site Request Forgery (CSRF) Prevention: A Practical Guide to Protecting Your Users

CSRF attacks trick authenticated users into performing unwanted actions. Learn how attackers exploit trust relationships and master the defense techniques that will keep your applications bulletproof.

Javier Rodriguez
Javier RodriguezFull-Stack Developer & Security Researcher

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:

  1. The victim is authenticated to a target application (your banking site, for example)
  2. The attacker crafts a malicious request that performs some action on that application
  3. The victim is tricked into triggering that request (clicking a link, loading an image, visiting a page)
  4. The browser dutifully sends the victim's authentication cookies along with the malicious request
  5. 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:

  1. Generate a unique, unpredictable token for each user session (or even each request)
  2. Include this token in every form or as a custom header for AJAX requests
  3. 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 AJAX
  • None: The old behavior—cookies always sent (requires Secure flag)
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.

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:

  1. Set a random value in a cookie (accessible to JavaScript)
  2. Include that same value in a request header or form field
  3. 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:

  1. Manual testing: Try submitting forms without the token or with a modified token
  2. Browser dev tools: Confirm SameSite attributes are set correctly on session cookies
  3. Automated scanning: Tools like OWASP ZAP can identify missing CSRF protection
  4. 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:

  1. Set SameSite=Lax on all session cookies (or Strict if your UX can handle it)
  2. Use your framework's CSRF protection for all state-changing requests
  3. Require custom headers for API endpoints to leverage CORS protection
  4. Never use GET for state-changing operations
  5. 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.

Javier Rodriguez
Written byJavier RodriguezFull-Stack Developer & Security Researcher
Read more articles