Skip to content

Cross-Site Request Forgery

💡
Before you start

You need Python 3 and a text editor — nothing else, and no internet. Every script below is a few lines you run locally. Check with python3 --version in a terminal (on Windows, py --version); any 3.x is fine. If it prints nothing useful, do Introduction to Python first — about fifteen minutes.

🔴 Everything here runs on your own machine, against files you create. The validation ideas apply to any language and any web framework; Python is just the shortest way to see them work.

What is CSRF?

Cross-Site Request Forgery (CSRF) is a web security vulnerability that tricks an authenticated user's browser into sending unintended requests to a web application. The attacker exploits the trust that a website has in the user's browser, not the trust the user has in the website.

Unlike Cross-Site Scripting (XSS) which exploits the trust a user has for a particular site, CSRF exploits the trust a site has in a user's browser. When you are logged into a banking website, your browser automatically includes your session cookie with every request to that domain. An attacker can craft a malicious page that triggers requests to your bank, and your browser will dutifully attach your credentials.

💡
CSRF is also known as

You may see CSRF referred to as "session riding," "XSRF," or "one-click attack." The OWASP Top 10 has consistently listed it as a critical web application vulnerability.

How CSRF Attacks Work

A CSRF attack follows a predictable pattern. Understanding this chain is critical to building effective defenses.

1
The victim authenticates to a legitimate web application (e.g., their bank) and receives a session cookie
2
The attacker crafts a malicious page or email containing a hidden request to the target application
3
The victim visits the attacker's page (or opens the email) while still logged into the target application
4
The browser sends the forged request with the victim's session cookie automatically attached
5
The server processes the request as legitimate because the session cookie is valid

Here is what a simple CSRF attack looks like in practice. An attacker could embed this invisible form on any website:

<!-- Hidden on attacker's website -->
<form action="https://bank.example.com/transfer" method="POST" id="csrf-form">
    <input type="hidden" name="to_account" value="attacker-account-789" />
    <input type="hidden" name="amount" value="5000" />
</form>
<script>document.getElementById('csrf-form').submit();</script>

When the victim loads this page, the form auto-submits. Their browser sends the POST request to the bank with their active session cookie, and the transfer executes without the victim ever clicking a button.

⚠️
GET requests are even easier to exploit

If a state-changing action uses GET (e.g., /delete?id=42), the attacker only needs an image tag: <img src="https://target.com/delete?id=42">. This is why state-changing operations must never use GET requests.

Real-World CSRF Examples

CSRF vulnerabilities have been found in major applications throughout web security history, demonstrating that even well-resourced teams can overlook this class of attack.

  • Netflix (2006) - Attackers could change account email addresses and passwords through CSRF, effectively taking over accounts. The attack only required the victim to visit a malicious page while logged into Netflix.
  • ING Direct (2008) - A CSRF vulnerability allowed attackers to open additional accounts, transfer funds between the victim's accounts, and then withdraw from the newly created account.
  • YouTube (2008) - Researchers demonstrated CSRF attacks that could subscribe victims to channels, add videos to favorites, and send messages on behalf of the victim without any interaction.
  • Router administration panels - Many home routers have been vulnerable to CSRF attacks that change DNS settings, effectively redirecting all of the victim's traffic through an attacker-controlled server.
💡
CSRF and APIs

Traditional CSRF attacks target browser-based sessions with cookies. APIs that use token-based authentication (like Bearer tokens in the Authorization header) are generally not vulnerable to CSRF because the browser does not automatically attach these tokens. However, APIs that rely on cookies for authentication remain at risk.

CSRF Tokens

The most widely used defense against CSRF is the synchronizer token pattern. The server generates a unique, unpredictable token for each session (or each form), embeds it in the HTML, and validates it when the form is submitted. An attacker cannot read this token from a cross-origin page, so they cannot forge a valid request.

Server-Side Token Generation (Python/Flask example)

import secrets

@app.before_request
def generate_csrf_token():
    if 'csrf_token' not in session:
        session['csrf_token'] = secrets.token_hex(32)

@app.route('/transfer', methods=['POST'])
def transfer():
    token = request.form.get('csrf_token')
    if not token or token != session.get('csrf_token'):
        abort(403, 'CSRF token validation failed')
    # Process the legitimate request
    process_transfer(request.form)

HTML Form with CSRF Token

<form action="/transfer" method="POST">
    <input type="hidden" name="csrf_token" value="{{ session.csrf_token }}" />
    <label for="to_account">Recipient Account:</label>
    <input type="text" name="to_account" id="to_account" />
    <label for="amount">Amount:</label>
    <input type="number" name="amount" id="amount" />
    <button type="submit">Transfer</button>
</form>

CSRF Tokens for AJAX Requests

For JavaScript-driven applications, the token is typically sent in a custom HTTP header:

// Read token from meta tag or cookie
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;

fetch('/api/transfer', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'X-CSRF-Token': csrfToken
    },
    body: JSON.stringify({ to_account: '12345', amount: 100 })
});

The server validates the X-CSRF-Token header against the session token. Cross-origin requests cannot set custom headers without CORS permission, which provides an additional layer of protection.

SameSite Cookies

The SameSite cookie attribute is a browser-level defense that controls whether cookies are sent with cross-site requests. It is one of the most effective modern mitigations against CSRF.

SameSite=Strict The cookie is never sent with cross-site requests. This provides the strongest CSRF protection but can affect usability (e.g., clicking a link from an email to your bank will not send the session cookie, requiring re-login).
SameSite=Lax The cookie is sent with top-level navigation GET requests (clicking a link) but not with cross-site POST requests, form submissions from other sites, or requests initiated by JavaScript. This is the default in most modern browsers.
SameSite=None The cookie is sent with all cross-site requests. Requires Secure flag. Only use this when cross-site cookie access is explicitly needed (e.g., embedded iframes, third-party integrations).
# Setting SameSite cookies (HTTP response header)
Set-Cookie: session_id=abc123; SameSite=Lax; Secure; HttpOnly; Path=/

# In Express.js
app.use(session({
    cookie: {
        sameSite: 'lax',
        secure: true,
        httpOnly: true
    }
}));

# In PHP
session_set_cookie_params([
    'samesite' => 'Lax',
    'secure'   => true,
    'httponly'  => true
]);
⚠️
SameSite alone is not sufficient

While SameSite=Lax blocks most CSRF attacks, it still allows GET-based CSRF through top-level navigation. If your application has any state-changing GET endpoints, SameSite=Lax will not protect them. Always combine SameSite cookies with CSRF tokens for defense in depth.

Defense in Depth

No single defense is bulletproof. A robust CSRF protection strategy layers multiple controls so that if one mechanism fails, others remain effective.

  • CSRF tokens - Include unique, per-session (or per-request) tokens in every state-changing form and validate them server-side
  • SameSite cookies - Set SameSite=Lax (or Strict) on all session cookies to prevent cross-site cookie transmission
  • Custom request headers - Require a custom header (e.g., X-Requested-With) on API calls; browsers block cross-origin custom headers without CORS
  • Origin/Referer validation - Check the Origin or Referer header to verify requests come from your own domain
  • Re-authentication for sensitive actions - Require password or MFA confirmation for high-impact operations like password changes or large transfers
  • Use POST for state changes - Never use GET requests for actions that modify data; this prevents trivial image-tag-based CSRF

Origin Header Validation Example

def validate_origin(request):
    allowed_origins = ['https://yourapp.example.com']
    origin = request.headers.get('Origin')
    referer = request.headers.get('Referer')

    # Origin header is present on POST/PUT/DELETE
    if origin:
        return origin in allowed_origins

    # Fall back to Referer for GET requests
    if referer:
        from urllib.parse import urlparse
        parsed = urlparse(referer)
        return f"{parsed.scheme}://{parsed.netloc}" in allowed_origins

    # If neither header is present, reject the request
    return False

Testing for CSRF

Testing your application for CSRF vulnerabilities is essential before deployment. Here are the key steps and tools to verify your defenses.

Manual Testing Checklist

  • Identify all state-changing endpoints (forms, API calls that modify data)
  • For each endpoint, attempt to submit a request from a different origin without a CSRF token
  • Verify that requests without valid CSRF tokens are rejected with a 403 status
  • Test whether tokens are tied to the session (try using a token from a different session)
  • Confirm that GET requests cannot trigger state changes
  • Check that SameSite cookie attributes are set correctly using browser developer tools

Using Browser Developer Tools

# In the browser console, check cookie attributes:
# Open DevTools > Application > Cookies
# Verify each session cookie shows:
#   SameSite: Lax (or Strict)
#   Secure: true (checkmark)
#   HttpOnly: true (checkmark)

# Test a forged request from the console:
fetch('https://yourapp.com/api/transfer', {
    method: 'POST',
    credentials: 'include',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ amount: 100 })
});
# This should be rejected (403) if CSRF protection is working

Automated Tools

  • OWASP ZAP - Free, open-source scanner that automatically identifies CSRF vulnerabilities in forms and AJAX endpoints
  • Burp Suite - Professional-grade tool with a CSRF PoC generator that creates attack pages to verify exploitability
  • CSRFTester - Dedicated OWASP tool for generating and testing CSRF proof-of-concept attacks
💡
Framework-level protection

Most modern web frameworks include built-in CSRF protection. Django has {% csrf_token %}, Rails has protect_from_forgery, Laravel has @csrf, and Express has csurf middleware. Always enable your framework's CSRF protection rather than building your own from scratch.

Now Do It Yourself: Five Steps

CSRF is one trusted browser being tricked into making a request it did not intend, carrying cookies it should not have sent. You will build the standard defence — a secret token the attacker cannot know — watch a forged request bounce off it, model exactly when a SameSite cookie is and is not sent, and then find a look-alike-domain hole in a careless origin check and close it. Every output below was captured by running the code on Python 3.12; the logic is the same in any web framework.

1
Plant a secret token the attacker cannot know

Go: open a terminal, make a folder with mkdir csrf-lab and cd csrf-lab, and open your text editor.

Do: save these thirteen lines as token.py, then run python3 token.py.

import secrets, hmac

session_token = secrets.token_hex(16)
print("token planted in the form:", session_token)

def handle_transfer(submitted_token, amount):
    if not submitted_token or not hmac.compare_digest(submitted_token, session_token):
        return "REJECTED: bad or missing CSRF token"
    return f"OK: transferred {amount}"

print("honest form submit:", handle_transfer(session_token, 100))
print("attacker's forged request:", handle_transfer(None, 100))
print("attacker guesses a token:", handle_transfer("0"*32, 100))

You should see: a random token printed, then honest form submit: OK: transferred 100, then two rejections — the forged request that sent no token, and the guess that sent the wrong one. (Your token is random and will differ each run; what matters is which lines say OK.) The idea is the whole of CSRF defence: the server plants an unpredictable token in its own form, and only a page that could read that form — a page on your own site — can send it back. An attacker’s page can make your browser fire a request, but it cannot read your token to include it.

If not: if the forged request is accepted, your check is missing the not submitted_token half — None must be rejected before it reaches compare_digest. And note it is hmac.compare_digest, not ==: comparing secrets with == leaks their length and content through timing, the same flaw as password checks.

2
See why the attacker is stuck

Go: same file.

Do: save these twelve lines as token2.py — a fresh, complete file — then run python3 token2.py. It hands the attacker a perfectly valid token that is simply not the server’s.

import secrets, hmac

session_token = secrets.token_hex(16)

def handle_transfer(submitted_token, amount):
    if not submitted_token or not hmac.compare_digest(submitted_token, session_token):
        return "REJECTED: bad or missing CSRF token"
    return f"OK: transferred {amount}"

attacker_own_token = secrets.token_hex(16)
print("honest form submit:", handle_transfer(session_token, 100))
print("attacker with their OWN token:", handle_transfer(attacker_own_token, 100))

You should see: the honest submit still says OK, and attacker with their OWN token: REJECTED: bad or missing CSRF token. The attacker can generate perfectly valid-looking tokens all day — they just cannot generate your token, because it lives in your session on the server and in a form only your site can serve. A CSRF token does not need to be secret from the user; it needs to be unguessable and tied to the session, which secrets.token_hex and the server-side check together give you.

If not: if the attacker’s own token is accepted, you compared it against itself instead of against session_token — the server must check the submission against the token it stored, never against whatever arrived in the request.

3
Model the second lock: SameSite cookies

Go: same folder, new file.

Do: save these thirteen lines as samesite.py, then run it.

def cookie_sent(same_site, request_is_cross_site, is_top_level_navigation):
    if same_site == "Strict":
        return not request_is_cross_site
    if same_site == "Lax":
        return (not request_is_cross_site) or is_top_level_navigation
    return True  # None: always sent (and then the cookie must be Secure)

print("Strict, forged POST:", cookie_sent("Strict", True, False))
print("Lax,    forged POST:", cookie_sent("Lax", True, False))
print("None,   forged POST:", cookie_sent("None", True, False))
print("Lax, clicking a link to the bank:", cookie_sent("Lax", True, True))

You should see: False, False, True, True. Read them as the rule they encode. A hidden auto-submitting form on the attacker’s page is a cross-site POST that is not a top-level navigation, so Strict and Lax both withhold the cookie — and with no session cookie, the forged request is just an anonymous stranger. SameSite=None sends it anyway, which is why that setting is dangerous and must at least be Secure. The last line shows why Lax is the common default: a normal top-level click to your site still carries the cookie, so ordinary links keep working.

If not: nothing here can crash — it is pure logic. If a value surprises you, trace it against the definition: Lax sends the cookie only when the request is same-site or a top-level navigation, which is exactly what makes it block the forged background POST while allowing the honest click.

4
Add an origin check — and watch it fail

Go: same folder, new file.

Do: save these nine lines as origin.py, then run it.

SITE = "https://bank.example"

def origin_ok(headers):
    origin = headers.get("Origin") or headers.get("Referer", "")
    return origin.startswith(SITE)

print("same-site form:", origin_ok({"Origin": "https://bank.example"}))
print("attacker's site:", origin_ok({"Origin": "https://evil.example"}))
print("look-alike prefix:", origin_ok({"Origin": "https://bank.example.evil.com"}))

You should see: True, False, then a third True that should alarm you. https://bank.example.evil.com is a domain the attacker owns, yet it passed — because startswith("https://bank.example") is happy with anything that begins with those characters. A prefix test on a hostname is a classic self-inflicted CSRF hole: the check looks present and is worse than useless, because it lends false confidence.

If not: if the third line is False, your SITE has a trailing slash or path that happens to block this particular payload — try https://bank.example.attacker.io and it returns. The problem is the method, not the exact string, which is why the fix in step 5 stops matching prefixes entirely.

5
Close it: match the origin exactly

Go: same folder, new file.

Do: save these ten lines as origin_fixed.py, then run it.

ALLOWED = {"https://bank.example", "https://www.bank.example"}

def origin_ok(headers):
    origin = headers.get("Origin") or headers.get("Referer", "")
    return origin in ALLOWED

print("same-site form:  ", origin_ok({"Origin": "https://bank.example"}))
print("attacker's site: ", origin_ok({"Origin": "https://evil.example"}))
print("look-alike prefix:", origin_ok({"Origin": "https://bank.example.evil.com"}))

You should see: True, False, False — the look-alike is now refused. The change is small and total: test the origin for exact membership in a set of full origins you control, never a prefix or a substring. Each allowed origin is written out in full, scheme included, so bank.example.evil.com is simply not in the set. This is defence in depth — the token in step 1 is your primary lock, SameSite in step 3 is the second, and an exact origin check is a cheap third that costs nothing to add.

If not: if the look-alike still passes, you are testing with startswith or in origin (substring) rather than origin in ALLOWED (membership). The direction matters: you ask “is this exact origin one I allow?”, never “does my site’s name appear somewhere in what they sent?”.

🎉
Check yourself before moving on

Without scrolling up: an attacker’s page can make your browser send a POST to your bank, cookies and all. Why can it still not pass the step-1 token check? Answer: the token lives in a form served only by your own site, and the browser’s same-origin policy stops the attacker’s page from reading it. They can trigger a request but cannot include a token they are not allowed to see.

Now do it without the page: the bank also exposes a JSON API that reads the token from an X-CSRF-Token header instead of a form field. Adapt handle_transfer to pull the token from a headers dict, and confirm a request with no such header is rejected. You have every piece — the same compare_digest check, reading headers.get("X-CSRF-Token") in place of the form field.

Summary

In this tutorial, you learned:

  • What CSRF is and why it exploits the browser's automatic cookie behavior
  • The step-by-step mechanics of a CSRF attack, from authentication to forged request
  • Real-world CSRF vulnerabilities found in Netflix, YouTube, and banking applications
  • How to implement CSRF tokens in both traditional forms and AJAX requests
  • The three SameSite cookie modes (Strict, Lax, None) and when to use each
  • Defense-in-depth strategies combining tokens, cookies, headers, and re-authentication
  • Manual and automated approaches to testing for CSRF vulnerabilities
🎉
You can now defend against CSRF attacks!

By combining CSRF tokens, SameSite cookies, and proper request validation, your web applications will be resilient against cross-site request forgery. Always test your defenses and use your framework's built-in protections when available.