CSRF (Cross-Site Request Forgery), also called session riding, exploits a simple detail of how the web works: the browser automatically attaches the victim’s cookies to any request bound for a domain, no matter where the request comes from. If your application authorizes a state-changing action on the session cookie alone, a malicious site can trigger that action on the victim’s behalf, without ever seeing the cookie and without needing it.
The user only has to be authenticated on your application and open an attacker-controlled page. They never see a thing happen.
The anatomy of the attack
Imagine an application that changes the account email with this form:
<form action="https://app.example.com/account/email" method="POST">
<input name="email" value="new@user.com" />
<button>Save</button>
</form>
If the server accepts that POST by validating only the session cookie, the attacker hosts the following page:
<!-- attacker-page.com -->
<form id="x" action="https://app.example.com/account/email" method="POST">
<input type="hidden" name="email" value="attacker@evil.com" />
</form>
<script>document.getElementById('x').submit();</script>
When the authenticated victim opens that page, the form submits on its own. The browser attaches the app.example.com session cookie, the server sees a “valid” request and changes the account email to the attacker’s. The attacker then fires a “forgot my password” and takes over the account.
Note that the attacker never read the cookie. They only made the victim’s browser use it for them.
CSRF via GET is even easier
If a state-changing action is exposed via GET, you don’t even need a form. A plain image triggers the attack:
<img src="https://app.example.com/account/delete?confirm=true" />
Hence the golden rule: GET must never change state. GET requests have to be safe and idempotent.
Defense 1: anti-CSRF tokens
The classic protection is to require, on every state-changing request, a secret value that the attacker cannot know or guess.
Synchronizer token pattern
The server generates a random token per session (or per request), embeds it in the form, and validates it on receipt:
<form action="/account/email" method="POST">
<input type="hidden" name="csrf_token" value="b7f3c1e9a4d28f6e..." />
<input name="email" value="new@user.com" />
<button>Save</button>
</form>
# Server-side validation (pseudo-Python)
def handle_post(request, session):
token_form = request.form.get("csrf_token")
token_session = session.get("csrf_token")
if not token_session or not constant_time_compare(token_form, token_session):
abort(403) # reject
update_email(request.form["email"])
Since the attacker’s site cannot read the token (the same-origin policy prevents reading another origin’s response), they have no way to fill in the field. Use constant-time comparison so the token doesn’t leak through timing.
Double-submit cookie
A stateless variant: the token goes both in a cookie and in a form field (or header), and the server checks that the two match. It works because the attacker can neither read nor set another origin’s cookie. One caveat: the technique becomes fragile if untrusted subdomains can write cookies on the parent domain. In that case, sign the token with HMAC to prevent forgery.
Defense 2: SameSite cookies
The SameSite attribute tells the browser to not send the cookie on cross-site requests, which goes after the root of CSRF:
Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax
SameSite=Lax(modern default in most browsers): the cookie follows top-level GET navigation but is not sent on cross-site POST or sub-requests (images, iframes). That alone neutralizes most vectors. Check whetherSecure,HttpOnlyandSameSiteare set on your cookies with the Security Headers Analyzer.SameSite=Strict: the cookie is never sent in a cross-site context, not even on links. It’s safer, but it can hurt UX, since a user arriving from an external link appears logged out.SameSite=None; Secure: required for legitimate cross-site scenarios such as SSO and widgets. It’s the mode that reopens the door to CSRF and brings tokens back into the picture.
SameSite is an excellent defense-in-depth layer, but it does not replace tokens. Old browsers ignore it, and the modes carry nuances of their own. Use both.
Defense 3: Origin / Referer verification
On state-changing requests, the server can check whether the Origin header (or, in its absence, Referer) matches the application itself:
ALLOWED = {"https://app.example.com"}
def is_same_origin(request):
origin = request.headers.get("Origin") or request.headers.get("Referer")
return origin and any(origin.startswith(o) for o in ALLOWED)
This is a robust and cheap defense for APIs, but handle the absence of the header carefully, since some proxies strip it. Combine it with a token rather than relying on it alone.
Common mistakes we see in pentests
- Token present but not validated: the field exists in the HTML, yet the server never checks it. Security theater.
- Global token not bound to the session: a static token shared across users buys you about as much as none.
- Only “obvious” endpoints protected: the password-change form has a token, but the JSON API endpoint that does the same thing has none.
- Accepting arbitrary
Content-Type: APIs that processapplication/jsonbut also acceptapplication/x-www-form-urlencodedlet a plain HTML form trigger the action. - State-changing GET: logout, deletion and toggles via GET still turn up all the time.
How we test CSRF
- Identify every state-changing action (not just forms: API endpoints too).
- For each one, remove/alter the token and check whether the request is still accepted.
- Inspect
SameSiteon session cookies and the handling ofOrigin/Referer. - Build a real cross-site PoC (auto-submit form or
fetch) and confirm execution with the victim’s session. - Assess impact by the criticality of the hijackable action.
Mitigation checklist
- Anti-CSRF token per session, validated on every state-changing action (web and API).
- Constant-time token comparison.
- Session cookies with
SameSite=Lax(orStrict) +HttpOnly+Secure. -
Origin/Refererverification on sensitive endpoints. - No state-changing action via GET.
- For SPAs/APIs: require a custom header (e.g.,
X-CSRF-Token) that cross-site cannot set without CORS. - Re-authentication on critical operations (changing email, password, MFA).
CSRF is a reminder that “being authenticated” and “having consented” are different things. A mature defense stacks token, SameSite and origin checking, so that no single failure can reopen the door on its own.


