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 originates. If your application authorizes a state-changing action based solely on the session cookie, a malicious site can trigger that action on the victim’s behalf, without ever seeing the cookie or needing it.

The user only has to be authenticated on your application and visit an attacker-controlled page. Everything else happens invisibly.

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 is submitted automatically. The browser attaches the app.example.com session cookie, the server sees a “valid” request and changes the account email to the attacker’s — who then fires a “forgot my password” and takes over the account.

Note: the attacker never read the cookie. They simply made the victim’s browser use it.

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" />

That’s why the golden rule is: GET must never change state. GET requests must 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 cannot fill in the field. Use constant-time comparison to avoid timing leaks.

A stateless variant: the token is sent both in a cookie and in a form field/header; the server checks that the two match. It works because the attacker can neither read nor set another origin’s cookie. Caveat: this technique becomes fragile if untrusted subdomains can write cookies on the parent domain — in that case, sign the token (HMAC) to prevent forgery.

Defense 2: SameSite cookies

The SameSite attribute instructs the browser to not send the cookie on cross-site requests, cutting CSRF at the root:

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). This already neutralizes most vectors. Check whether Secure, HttpOnly and SameSite are 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. Safer, but it can hurt UX (a user arriving from an external link appears logged out).
  • SameSite=None; Secure: required for legitimate cross-site scenarios (SSO, widgets) — and precisely what reopens the door to CSRF, requiring tokens.

SameSite is an excellent defense-in-depth layer, but it does not replace tokens: old browsers ignore it, and there are nuances between modes. 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 (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. A false sense of security.
  • Global token not bound to the session: a static token shared across users is as useful as none.
  • Only “obvious” endpoints protected: the password-change form has a token, but the JSON API endpoint that does the same thing does not.
  • Accepting arbitrary Content-Type: APIs that process application/json but also accept application/x-www-form-urlencoded let a plain HTML form trigger the action.
  • State-changing GET: logout, deletion and toggles via GET remain common.

How we test CSRF

  1. Identify every state-changing action (not just forms: API endpoints too).
  2. For each one, remove/alter the token and check whether the request is still accepted.
  3. Inspect SameSite on session cookies and the handling of Origin/Referer.
  4. Build a real cross-site PoC (auto-submit form or fetch) and confirm execution with the victim’s session.
  5. 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 (or Strict) + HttpOnly + Secure.
  • Origin/Referer verification 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 combines three layers — token, SameSite and origin checking — so that no single failure reopens the door.