Skip to content
New

DeepSeek V4.1 Flash free for 7 days: 10M tokens a day after any top-up. Until Nov 30.

How it works

What Is CSRF and How SameSite Cookies Help

Cross-site request forgery uses your browser's cookies against you. Learn the defences in order of strength and test SameSite with a Flask lab.

Intermediate12 min readBy NotHoly

Cross-site request forgery (CSRF) lets a page on one site make your browser send a state-changing request to another site where you are logged in. The browser attaches your session cookie on its own, so the other site treats the request as yours. The defences are a per-session secret the attacker cannot read, the SameSite cookie attribute, an Origin check, and a rule that GET requests never change state. This article covers each one, then runs a small Flask lab so you can see the cookie move.

What CSRF is

A web session usually lives in a cookie. After you log in to bank.example, your browser stores a cookie for that host and sends it with every request to that host. The server reads the cookie, finds your session, and runs the action.

The browser does not check where a request came from. If a page on evil.example contains a form that posts to https://bank.example/transfer, the browser sends that POST with your bank.example cookie attached. The bank sees a valid session and moves the money. The attacker never sees your cookie and never reads the response. The attack only needs the server to perform the action.

CWE-352 describes the weakness this way: the application performs a state-changing action for a user without checking that the user chose to make the request. The fix is to let the server tell a request the user made apart from one a third-party page forged.

Why it matters

Any app that keeps sessions in cookies and changes data on a request has this exposure. The actions that matter most are the ones that change credentials or settings: a password change, an email address change, a new API key, a new payment method, a forwarding rule. A forged email change is often the first step of an account takeover, because the attacker can then reset the password through the new address.

Cookie sessions are the default in many frameworks, and the browser sends the cookie whether or not the developer thought about it. That is why CSRF still appears on the OWASP and CWE lists.

How the attack works, step by step

  1. The victim logs in to bank.example. The browser stores the session cookie for that host.
  2. The victim opens another page in the same browser, without logging out. The page belongs to the attacker.
  3. The page contains a form that posts to https://bank.example/transfer with values the attacker chose. It can submit on load, so the victim does not need to click anything.
  4. The browser sends the POST and includes the bank.example cookie.
  5. The server validates the cookie, sees a logged-in user, and performs the transfer.

Notice what the attacker did not need. They did not steal the cookie, guess a password or read a single byte of the bank's response. The browser did the work.

Why GET must not change state

A GET request can be triggered without any form. An <img src="https://bank.example/delete?id=7"> tag makes the browser send a GET request as soon as the page renders. Prefetching, link previews and crawlers can send GET requests too. If a GET route changes data, CSRF needs no form at all.

The rule is simple: GET and HEAD read data and never change it. Changes go through POST, PUT, PATCH or DELETE. The rule is not a defence on its own, because a POST can still be forged, but every other defence works better when the rule holds.

The defences, in order of strength

1. Synchronizer tokens

The server creates a random token for each session, puts it in every form as a hidden field, and checks it on every state-changing request. A request without the right token is refused.

The token works because the attacker's page cannot read it. The same-origin policy stops evil.example from reading the HTML that bank.example returned, so the attacker cannot copy the hidden field into a forged form. A forged request arrives with the cookie and without the token.

The token must be unpredictable (use a cryptographic random generator), bound to the user's session, and checked on every unsafe method, including the endpoints your JavaScript calls. Most frameworks ship this protection. The OWASP cheat sheet lists token-based defences first for that reason.

Double-submit cookies are a weaker variant. The token goes in both a cookie and a request field, and the server compares them. An attacker who can set cookies on your domain, for example from a sibling subdomain, can defeat it. Prefer the synchronizer token when you can keep state on the server.

SameSite tells the browser when to attach a cookie to a request that starts on another site. The attribute has three values.

  • Strict sends the cookie only for requests that start on the same site. A link from an email into your app arrives without the cookie, so the user looks logged out on the first visit.
  • Lax sends the cookie for same-site requests and for top-level GET navigations from another site, such as following a link. It does not send the cookie on cross-site POST requests, or on requests that load images, frames and scripts from another site. Lax blocks the classic form-based attack.
  • None sends the cookie on cross-site requests too. It requires the Secure attribute, so the cookie travels only over HTTPS. Use None only when a real cross-site flow needs the cookie, such as an embedded third-party widget.

MDN documents the attribute and its values in the Set-Cookie reference. The RFC 6265bis draft builds "site" on the registrable domain (the public suffix plus the label to its left, so site.example for www.site.example). Port numbers do not count. Subdomains count as the same site. This matters later in the lab, because localhost and 127.0.0.1 are different hosts and therefore different sites.

SameSite is strong, but not complete. A sibling subdomain such as forum.bank.example is same-site with bank.example, so Lax does not stop an attack launched from there. Older browsers that ignore the attribute send the cookie everywhere. And Lax still sends the cookie on top-level GET navigations, which is why the GET rule stays in force. Treat SameSite as one layer.

3. Origin and Referer checks

Browsers send an Origin header on POST requests and on most cross-site requests. The header holds the scheme, host and port of the page that started the request, so the server can compare it with its own origin. Sec-Fetch-Site gives a similar signal, with values such as same-origin, same-site and cross-site.

The check is simple. Reject state-changing requests when Origin is present and does not match your origin. If Origin is missing, fall back to Referer. If both are missing on a state-changing request, reject it. Parse the header as a URL and compare the full origin. A substring check fails: Origin: https://bank.example.evil.test contains your domain but belongs to someone else.

Origin checks catch clear cross-site requests, but they depend on headers that some clients omit and that privacy settings can strip. Use them as a filter, and keep a token for the cases the headers miss.

4. Never change state on GET

This is a rule, not a control you can test with a single request. Keep it because it makes every other defence cover more ground. A route that changes state only on POST gives an attacker a form to forge, not an image tag to plant.

You need Python 3.12 and three terminal windows. This article's run used Python 3.12.10 on Windows 11 with Git Bash. The lab uses one Flask app on 127.0.0.1:5000 and a plain static page on localhost:8001. The second origin is what makes the request cross-site.

Set up the environment

Create a virtual environment and install Flask. On Linux and macOS, activate it with . .venv/bin/activate. On Windows with Git Bash, the activation script is at .venv/Scripts/activate.

bash
python3 -m venv .venv && . .venv/bin/activate && pip install flask

On this run the installed versions were Flask 3.1.3 and Werkzeug 3.1.9.

Write the app

Save this as app.py. The /login route sets a session cookie with SameSite=Lax and HttpOnly. The /transfer route changes nothing. It only prints whether the cookie arrived, along with two request headers that help you read the result.

python
import os

from flask import Flask, make_response, request

app = Flask(__name__)

# Change this to "None" to compare. Browsers reject SameSite=None without Secure.
SAMESITE = os.environ.get("LAB_SAMESITE", "Lax")
SECURE = SAMESITE == "None"


@app.get("/login")
def login():
    resp = make_response("logged in\n")
    resp.set_cookie("session", "lab", samesite=SAMESITE, secure=SECURE, httponly=True)
    return resp


@app.post("/transfer")
def transfer():
    arrived = request.cookies.get("session") == "lab"
    print(
        f"POST /transfer cookie_arrived={arrived} "
        f"origin={request.headers.get('Origin')} "
        f"sec_fetch_site={request.headers.get('Sec-Fetch-Site')}",
        flush=True,
    )
    return f"cookie_arrived={arrived}\n"

The app does not validate anything yet. That is deliberate. You want to see what the browser sends before you add a defence.

In the first terminal:

bash
flask --app app run --host 127.0.0.1 --port 5000

In a second terminal, request the login route and read the header:

bash
curl -i http://127.0.0.1:5000/login

Output from this run, with the Date line removed:

text
HTTP/1.1 200 OK
Server: Werkzeug/3.1.9 Python/3.12.10
Content-Type: text/html; charset=utf-8
Content-Length: 10
Set-Cookie: session=lab; HttpOnly; Path=/; SameSite=Lax
Connection: close

logged in

The Set-Cookie line is the important part. HttpOnly keeps the cookie away from page JavaScript. It does not stop the browser from sending the cookie, so HttpOnly is not a CSRF defence. SameSite=Lax is the attribute that changes what the browser sends across sites.

Simulate what the server sees

Before you involve a browser, send requests yourself so you know what the server logs:

bash
curl -s -X POST -d "amount=100" http://127.0.0.1:5000/transfer
curl -s -X POST -H "Origin: http://localhost:8001" -H "Sec-Fetch-Site: cross-site" -b "session=lab" -d "amount=100" http://127.0.0.1:5000/transfer
curl -s -X POST -H "Origin: http://127.0.0.1:5000" -H "Sec-Fetch-Site: same-origin" -b "session=lab" -d "amount=100" http://127.0.0.1:5000/transfer

Responses from this run, one line per command:

text
cookie_arrived=False
cookie_arrived=True
cookie_arrived=True

The lines the Flask terminal printed for those three requests:

text
POST /transfer cookie_arrived=False origin=None sec_fetch_site=None
POST /transfer cookie_arrived=True origin=http://localhost:8001 sec_fetch_site=cross-site
POST /transfer cookie_arrived=True origin=http://127.0.0.1:5000 sec_fetch_site=same-origin

curl does not apply SameSite. It sends whatever you pass with -b. So these lines prove that the route reads the cookie and the headers. They do not prove what a browser sends. That is the next step.

Serve a form from a second origin

Create a folder named attacker and save this as attacker/index.html:

html
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Second origin (lab)</title></head>
<body>
<h1>Form on a second origin</h1>
<form method="POST" action="http://127.0.0.1:5000/transfer">
  <input type="hidden" name="amount" value="100">
  <button type="submit">Send transfer</button>
</form>
</body>
</html>

In a third terminal, start a static server from the folder that contains attacker/:

bash
python3 -m http.server 8001 --bind 127.0.0.1

On this run, a request to the static server returned HTTP 200. The page then loads at http://localhost:8001/attacker/. Note the host name. localhost and 127.0.0.1 are different hosts, so the form served from localhost:8001 posts to a different site than the cookie's host. That is the cross-site setup you need.

Compare SameSite=Lax with SameSite=None

Open http://127.0.0.1:5000/login in your browser first, so the cookie is stored for 127.0.0.1 in that browser profile. Then open http://localhost:8001/attacker/ in the same browser and click Send transfer. Read the Flask terminal.

With SameSite=Lax, a cross-site form POST should not carry the cookie. The expected log line is cookie_arrived=False. The browser omits the cookie because Lax does not send it on cross-site POST requests. The browser step was not run for this article, so check the log line yourself against this expectation.

Now restart the app with None:

bash
LAB_SAMESITE=None flask --app app run --host 127.0.0.1 --port 5000

Read the header on the new server:

bash
curl -i http://127.0.0.1:5000/login

Output from this run:

text
Set-Cookie: session=lab; Secure; HttpOnly; Path=/; SameSite=None

SameSite=None on its own is not enough. Browsers reject a None cookie that lacks Secure, and Secure requires HTTPS. The lab serves plain HTTP on 127.0.0.1, and whether a browser stores a Secure cookie from that origin depends on the browser. Open the cookie in your browser's developer tools and see whether it exists. If it was not stored, the server log shows cookie_arrived=False, but the reason is the Secure rule, not SameSite. If it was stored, run the form again. A cookie_arrived=True line with None means the browser sent a cookie on a cross-site POST. That is the behaviour that makes None risky for session cookies, and it is why you should use Lax or Strict unless a real cross-site flow needs None.

Stop each server with Ctrl+C when you finish.

What the lab does not prove

The lab shows how cookies and SameSite change what the server receives. It does not add a defence. The /transfer route still accepts any POST that carries the cookie, so a SameSite=None cookie would let a forged form succeed. To close the gap, add a synchronizer token to the form and check it in the route. The lab stops here so you can see the attribute alone first.

Common mistakes

  • Trusting CORS to stop CSRF. CORS controls whether a page can read a response. It does not stop the browser from sending a simple cross-site POST. The request still reaches the server.
  • Thinking HttpOnly stops CSRF. HttpOnly hides the cookie from JavaScript. The browser still attaches it to forged requests.
  • Checking the token on some routes only. Endpoints that AJAX calls use often skip the check. Attackers look for the one route that does.
  • Putting the token in the URL. URLs appear in server logs, Referer headers and browser history.
  • Making GET routes change data. Then a plain image tag is enough to attack, and SameSite=Lax no longer protects the route.
  • Setting None without Secure. Browsers reject it. If you change the attribute to fix a broken flow, you may open a cross-site path you did not mean to open.
  • Matching Origin with a substring. evil-bank.example and bank.example.evil.test both contain bank.example. Compare full origins.
  • Relying on SameSite alone. It is strong against unrelated sites, but sibling subdomains are same-site, and older browsers may ignore the attribute.

How to go further

Read the OWASP cheat sheet from start to finish. It covers the token patterns, the header checks and the cases each one misses. Read the RFC 6265bis draft for the exact SameSite rules, including the default when the attribute is missing and how top-level navigations are treated. The MDN Set-Cookie page is a faster reference for the attribute values.

In your own app, use the CSRF protection your framework provides, and test it. Write one test that posts to a state-changing route without a token and expects a refusal. Write a second test that posts with a token from a different session and expects a refusal too. If you use Flask, look at the CSRFProtect extension from Flask-WTF. Check the browser's developer tools for cookie warnings, because Chrome reports SameSite problems in its Issues panel.

For the transport layer under these cookies, read what HTTPS and TLS do. The Secure attribute only means something when the connection is encrypted. The next article in this series, how to write your first security report, shows how to record a finding like this one for the team that owns the code.

Sources

Sources

  1. OWASP Cross-Site Request Forgery Prevention Cheat Sheet
  2. MDN, Set-Cookie header
  3. CWE-352, Cross-Site Request Forgery (CSRF)
  4. IETF draft-ietf-httpbis-rfc6265bis, HTTP State Management Mechanism

More in Web and API security

Web and API security

OWASP Top 10 Explained for Developers

What the OWASP Top 10 is, what each 2025 category means, how to defend it, and how to map Juice Shop challenges to it.

Beginner13 min read

Web and API security

SQL Injection Explained with OWASP Juice Shop

How SQL injection changes a query's meaning, why parameterized queries fix it, and a Juice Shop login lab with a Python sqlite3 demo.

Intermediate10 min read