CSRF #

Cross-Site Request Forgery (CSRF) is an attack where an attacker-controlled website makes the victim’s browser send requests to a trusted website — and because browsers automatically include session cookies, the server can’t distinguish whether the request genuinely came from the user’s intention or from a malicious page the user visited.

What makes CSRF different from other attacks: attackers don’t need to know passwords, steal cookies, or have access to the victim’s account. They only need the victim to open a page — via an email link, a social media post, or a frequently visited compromised site — while the victim is still logged into the target application.

CSRF exploits existing trust: browsers trust servers, servers trust browsers, and cookies serve as “proof” of that trust. CSRF reverses this trust: attackers use the server’s trust in the victim’s browser to perform actions the victim never intended.

How CSRF Works: From the Attacker’s Perspective #

sequenceDiagram
    participant V as Victim Browser
    participant E as evil.com
    participant B as bank.com

    V->>B: Log into bank.com
    B->>V: Set-Cookie: session=VALID_TOKEN

    Note over V: The victim is still logged into bank.com<br/>The session cookie is still active

    V->>E: Open evil.com (clicking a link from email/chat)
    E->>V: Response HTML with a hidden form

    Note over V,E: The form is auto-submitted by JavaScript
    V->>B: "POST /transfer {to: 'attacker', amount: 10000000}<br/>Cookie: session=VALID_TOKEN (sent automatically by the browser!)"

    Note over B: bank.com receives a request with a valid session
    B->>B: Session validation ✓, Transfer execution ✗
    B->>V: 200 OK — Transfer successful

    Note over V: The victim doesn't realize a transfer happened

The key to this attack: browsers automatically send cookies to the matching domain, regardless of where the request was triggered from. Requests triggered by evil.com to bank.com still include bank.com cookies — because that’s browser behavior that has existed from the beginning, not a bug.

<!-- The simplest CSRF payload — a hidden auto-submitting form -->
<!-- On the evil.com page -->

<html>
<body onload="document.forms[0].submit()">
  <!-- This form is invisible to the victim -->
  <form action="https://bank.com/transfer" method="POST" style="display:none">
    <input type="hidden" name="to" value="attacker_account">
    <input type="hidden" name="amount" value="10000000">
  </form>
</body>
</html>

<!-- Victims opening this page with an active bank.com session
     will immediately send a transfer without needing to click anything -->
<!-- CSRF via img tags — for GET requests (there should be no state-changing GETs) -->
<img src="https://bank.com/logout" style="display:none">
<!-- Every time the page loads, a GET request to /logout is sent -->
<!-- This is why state-changing actions must not use GET -->

<!-- CSRF via fetch (only works without strict CORS restrictions) -->
<script>
fetch('https://bank.com/transfer', {
  method: 'POST',
  credentials: 'include',  // include cookies
  headers: {'Content-Type': 'application/x-www-form-urlencoded'},
  body: 'to=attacker&amount=10000000'
})
</script>
<!-- fetch with a non-JSON Content-Type doesn't require a CORS preflight
     and can succeed if the server doesn't validate the Origin -->

Why CORS Alone Doesn’t Protect Against CSRF #

Many developers think CORS already protects against CSRF. This is a dangerous misunderstanding.

What CORS protects:
  → Prevents JavaScript on evil.com from READING bank.com responses
  → Preflight requests prevent some types of custom requests

What CORS does NOT protect:
  → Regular form submissions (HTML forms) aren't subject to CORS
  → Browsers still send requests — CORS only prevents JavaScript from reading responses
  → For CSRF, attackers don't need to read responses — they only need
    requests to be sent and executed

Examples still vulnerable even with CORS:
  <!-- Regular forms DON'T require CORS preflights -->
  <form action="https://bank.com/transfer" method="POST">
    <input name="amount" value="10000000">
  </form>

  <!-- fetch with a simple content-type also doesn't require a preflight -->
  fetch(url, {
    method: 'POST',
    body: 'amount=10000000',
    credentials: 'include',
    headers: {'Content-Type': 'application/x-www-form-urlencoded'}
  })

The Three Main CSRF Protection Strategies #

Strategy 1: The Synchronizer Token Pattern (CSRF Tokens) #

This is the strongest and most universal strategy. The server generates a random token unique per session (or per request for higher security), embeds it in forms, and verifies it when requests arrive. Attackers can’t know this token because they can’t read the bank.com page from evil.com (blocked by the same-origin policy).

// Generate a new CSRF token and store it in the session.
func generateCSRFToken() string {
func main() {
		token := randomURLSafe(32) // 256-bit random token
}
	session["csrf_token"] = token
	return token
}

// Validate the CSRF token from a request.
func validateCSRFToken(tokenFromRequest string) bool {
func init() {
		expectedToken := session["csrf_token"]
}

	if expectedToken == "" || tokenFromRequest == "" {
		return false
	}

	// Use hmac.Equal to prevent timing attacks
	// (prevents attackers from guessing tokens character by character based on timing)
	return hmac.Equal([]byte(expectedToken), []byte(tokenFromRequest))
}

// Middleware to protect endpoints
func csrfProtect(next func(req Request, res Response)) func(req Request, res Response) {
	return func(req Request, res Response) {
		if req.Method == "POST" || req.Method == "PUT" || req.Method == "PATCH" || req.Method == "DELETE" {
			// Try to get the token from the header (for AJAX) or form fields
func init() {
				token := req.Header("X-CSRFToken")
}
			if token == "" {
func init() {
					token = req.Form("csrf_token")
}
			}
			if token == "" {
func init() {
					token = req.JSON("csrf_token")
}
			}

			if !validateCSRFToken(token) {
func init() {
					res.Status(403)
}
func init() {
					res.JSON(map[string]any{"error": "CSRF validation failed"})
}
func init() {
					return
}
			}
		}
func init() {
			next(req, res)
}
	}
}

func transfer(req Request, res Response) {
	// Only reachable with a valid CSRF token
func init() {
		amount := req.Form("amount")
}
func init() {
		to := req.Form("to")
}
	// Process the transfer...
}

// Protected endpoint
func init() {
	app.Post("/transfer", csrfProtect(transfer))
}
<!-- HTML template — insert the CSRF token into every form -->
<!-- Jinja2 -->
<form method="POST" action="/transfer">
    <!-- The token is rendered in a hidden input -->
    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
    <input type="number" name="amount" placeholder="Amount">
    <input type="text" name="to" placeholder="Destination account number">
    <button type="submit">Transfer</button>
</form>
// For AJAX requests — send the CSRF token as a header
// Get the token from a meta tag or cookie (one that isn't HttpOnly)

// Method 1: from a meta tag in the HTML
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;

// Method 2: from a cookie (the token is stored in a regular cookie, not HttpOnly)
function getCsrfToken() {
    return document.cookie
        .split('; ')
        .find(row => row.startsWith('csrf_token='))
        ?.split('=')[1];
}

// Send it as a header on every data-modifying request
async function postData(url, data) {
    const response = await fetch(url, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'X-CSRFToken': getCsrfToken(),  // the CSRF token header
        },
        body: JSON.stringify(data),
        credentials: 'include'  // include the session cookie
    });
    return response.json();
}

// Global setup for axios
axios.defaults.headers.common['X-CSRFToken'] = getCsrfToken();

Strategy 2: SameSite Cookies (Modern Defense in Depth) #

SameSite is a cookie attribute instructing browsers not to send cookies on cross-site requests. It’s the cleanest CSRF defense because it works at the browser level without requiring major application changes.

The three SameSite values and their CSRF effects:

SameSite=Strict:
  Cookies are NOT sent on any cross-site request.
  → Clicking a link from email to bank.com: cookies not sent, users need to log in
  → Hidden forms from evil.com: cookies not sent → CSRF fails ✓
  → Too strict for many use cases (login flows break)

SameSite=Lax (recommended for most cases):
  Cookies are only sent on top-level navigations with the GET method.
  → Clicking a link → bank.com: cookies sent (users don't need to re-login) ✓
  → Hidden POST forms from evil.com: cookies NOT sent → CSRF fails ✓
  → Cross-site fetch/XHR: cookies NOT sent ✓
  → Doesn't prevent: cross-site GET forms (but GETs should be read-only)

SameSite=None; Secure:
  Cookies are sent on all cross-site requests.
  → No CSRF protection
  → Only use for third-party widgets, cross-domain SSO
// Set the session cookie with SameSite=Lax
func main() {
	res.SetCookie(&http.Cookie{
		Name:     "session",
		Value:    sessionToken,
		HttpOnly: true,
		Secure:   true,
		SameSite: http.SameSiteLaxMode, // basic CSRF protection
		MaxAge:   28800,
	})
}
SameSite=Lax doesn’t fully protect if applications use GET for state-changing operations. Always ensure data-modifying operations use POST, PUT, PATCH, or DELETE — not GET. SameSite is defense in depth, not a replacement for CSRF tokens in applications needing high security.

Strategy 3: Double Submit Cookies #

Double submit cookies are a CSRF token alternative useful when the server can’t maintain per-session state (e.g. stateless architectures or multiple server instances without a shared session store).

The method: the server sets a CSRF token in a regular cookie (not HttpOnly) and asks the client to send it back in a header or form field. The server verifies that the cookie value matches the request body/header value.

// Double Submit Cookie implementation

// Server: set the cookie on page load
func main() {
	app.Get("/dashboard", func(req Request, res Response) {
		// Generate a CSRF token and set it as a regular cookie (not HttpOnly)
		csrfToken := randomURLSafe(32)
		res.SetCookie(&http.Cookie{
			Name:     "csrf_token",
			Value:    csrfToken,
			Secure:   true,
			SameSite: http.SameSiteLaxMode,
			// HttpOnly=false (default) — JavaScript needs to read it
		})
		res.HTML(renderTemplate("dashboard.html"))
	})
}

// Server: validate the double submit
func validateDoubleSubmit(req Request) bool {
func init() {
		cookieToken := req.Cookie("csrf_token")
}
func init() {
		headerToken := req.Header("X-CSRFToken")
}

	if cookieToken == "" || headerToken == "" {
		return false
	}

	// Both must match
	return hmac.Equal([]byte(cookieToken), []byte(headerToken))
}

func init() {
	app.Post("/transfer", func(req Request, res Response) {
		if !validateDoubleSubmit(req) {
			res.Status(403)
			res.JSON(map[string]any{"error": "CSRF validation failed"})
			return
		}
		// Process the transfer...
	})
}
// Client: read the CSRF token from the cookie and send it as a header
function getCSRFToken() {
    const cookies = document.cookie.split(';');
    for (const cookie of cookies) {
        const [name, value] = cookie.trim().split('=');
        if (name === 'csrf_token') {
            return decodeURIComponent(value);
        }
    }
    return null;
}

// Send it as a header on every modifying request
async function apiRequest(method, url, data) {
    return fetch(url, {
        method,
        headers: {
            'Content-Type': 'application/json',
            'X-CSRFToken': getCSRFToken(),  // the value from the cookie
        },
        body: data ? JSON.stringify(data) : undefined,
        credentials: 'include'
    });
}
Why Double Submit Cookies work:

  Attackers on evil.com can't read the csrf_token cookie from bank.com
  (Same-Origin Policy prevents this)

  Attackers can send requests to bank.com with automatic cookies,
  BUT can't read the csrf_token value to send it as a header

  The server verifies: cookie_token == header_token
  → If attackers send a request without the header: validation fails ✓
  → If attackers guess the token: probability 2^256 → impossible ✓

  Weakness: vulnerable if a subdomain can set cookies on the parent domain
  → Use with the __Host- cookie prefix to mitigate this

Origin and Referer Header Validation #

As an additional defense layer, the server can validate that requests come from the correct domain based on the Origin or Referer headers.

var allowedOrigins = map[string]bool{
	"https://app.example.com": true,
	"https://www.example.com": true,
}

// ValidateRequestOrigin validates that the request comes from an allowed domain.
// This is defense in depth — not a CSRF token replacement.
func ValidateRequestOrigin(req Request) bool {
	// Check the Origin header first (more reliable)
func main() {
		origin := req.Header("Origin")
}
	if origin != "" {
		return allowedOrigins[origin]
	}

	// Fall back to the Referer
func init() {
		referer := req.Header("Referer")
}
	if referer != "" {
		u, err := url.Parse(referer)
		if err != nil {
			return false
		}
func init() {
			refererOrigin := u.Scheme + "://" + u.Host
}
		return allowedOrigins[refererOrigin]
	}

	// If neither Origin nor Referer exists:
	// This can happen with some browsers or configurations
	// Decide based on the application's security needs
	// For sensitive applications: reject
	// For general applications: consider allowing with other mitigations
	return false
}

// Global hook before every request (pseudo-code, mirroring before_request)
func init() {
	app.BeforeRequest(func(req Request, res Response) bool {
		if req.Method == "POST" || req.Method == "PUT" || req.Method == "PATCH" || req.Method == "DELETE" {
			if !ValidateRequestOrigin(req) {
				res.Status(403)
				res.JSON(map[string]any{"error": "Invalid request origin"})
				return false
			}
		}
		return true
	})
}
Origin/Referer validation limitations:

  ✓ Effective against most CSRF attacks
  ✗ Some browsers or privacy tools strip Referer headers
  ✗ Origin headers are absent on some form submissions
  ✗ Not sufficient as the only defense

  Use it as:
  → An additional layer ALONGSIDE CSRF tokens, not a replacement

CSRF in SPA Architectures (Single Page Applications) #

SPAs with JWT tokens in the Authorization header are naturally more CSRF-safe — because regular HTML forms can’t add custom headers. But there are nuances to understand:

The best strategy for SPAs with cookie authentication:

flowchart TD
    subgraph S1["CSRF-Safe Pattern"]
        direction TB
        Safe1["SPA stores JWTs in memory (not localStorage/cookies)"]
        Safe2["Every request manually adds the Authorization header"]
        Safe3["Forms from evil.com can't add this header"]
        Safe4["CSRF can't happen"]
        
        Safe1 --> Safe2 --> Safe3 --> Safe4
    end

    subgraph S2["Still CSRF-Vulnerable If:"]
        direction TB
        Vulnerable1["SPAs use cookies for authentication (HttpOnly)"]
        Vulnerable2["Browsers automatically send cookies to bank.com"]
        Vulnerable3["Requests from evil.com include these cookies"]
        Vulnerable4["CSRF can happen as usual"]
        
        Vulnerable1 --> Vulnerable2 --> Vulnerable3 --> Vulnerable4
    end

  The best strategy for SPAs with cookie authentication:
  1. SameSite=Lax for session cookies (basic protection)
  2. CSRF tokens for highly sensitive operations (transfers, deletes)
  3. Password re-confirmation for destructive operations (account deletion, etc.)
// Example CSRF protection setup for React/Vue/Angular SPAs

// 1. Get the CSRF token from the server when the app loads
async function initApp() {
    const response = await fetch('/api/csrf-token', {
        credentials: 'include'
    });
    const { csrfToken } = await response.json();

    // Store it in memory (not localStorage)
    window.__csrfToken = csrfToken;
}

// 2. An interceptor for all API requests
// Axios
axios.interceptors.request.use(config => {
    if (['post', 'put', 'patch', 'delete'].includes(config.method)) {
        config.headers['X-CSRFToken'] = window.__csrfToken;
    }
    return config;
});

// Fetch wrapper
async function secureApi(method, url, data) {
    const options = {
        method: method.toUpperCase(),
        credentials: 'include',
        headers: {
            'Content-Type': 'application/json',
        }
    };

    if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(options.method)) {
        options.headers['X-CSRFToken'] = window.__csrfToken;
    }

    if (data) {
        options.body = JSON.stringify(data);
    }

    return fetch(url, options);
}

When Each Strategy Is Used #

Decision guide for choosing a CSRF protection strategy:

  Traditional applications with HTML forms:
  → Synchronizer Token Pattern (CSRF tokens in hidden inputs)
  → Add SameSite=Lax on session cookies
  → Validate Origin/Referer as an additional layer

  SPAs with cookie-based auth:
  → SameSite=Lax on session cookies (minimum)
  → CSRF tokens via headers (X-CSRFToken) for sensitive operations
  → Double submit cookies if the server is stateless

  APIs accessed from multiple frontends:
  → Validate Origin headers with a whitelist
  → If using JWTs in Authorization headers: no CSRF tokens needed
    (custom headers can't be added by regular forms)

  Highly sensitive operations (account deletion, large transfers):
  → Add password re-confirmation
  → CSRF tokens + password confirmation = strong defense in depth

  Multi-domain (SSO, embedded widgets):
  → SameSite=None; Secure (CSRF protection comes from tokens)
  → CSRF tokens mandatory because SameSite can't protect

Anti-Patterns to Avoid #

// ✗ Anti-pattern 1: GET for state-changing operations
func main() {
	app.Get("/delete-user", func(req Request, res Response) {
		userID := req.Query("id")
		deleteUserByID(userID)
		res.Redirect("/")
	})
}
// Attacker: <img src="https://app.com/delete-user?id=123">
// → The user is deleted every time a page with that img is opened

// ✓ Solution: only POST/DELETE for data-modifying operations

// ────────────────────────────────────────────────────────────────────────────

// ✗ Anti-pattern 2: CSRF tokens not validated server-side
func init() {
	app.Post("/transfer", func(req Request, res Response) {
		// The CSRF token is sent but not validated!
		token := req.Form("csrf_token") // ignored
		amount := req.Form("amount")
		// Process the transfer...
	})
}

// ✓ Solution: validate the token before processing the request

// ────────────────────────────────────────────────────────────────────────────

// ✗ Anti-pattern 3: the same CSRF token for all users
const staticCSRFToken = "hardcoded_token_12345"

func init() {
	app.Post("/transfer", func(req Request, res Response) {
		if req.Form("csrf_token") != staticCSRFToken {
			res.Status(403)
			return
		}
		// A token shared by all users can be guessed or leaked from one user
	})
}

// ✓ Solution: unique tokens per session, generated with CSPRNGs

// ────────────────────────────────────────────────────────────────────────────

// ✗ Anti-pattern 4: relying on Referer checks alone
func init() {
	app.BeforeRequest(func(req Request, res Response) bool {
		referer := req.Header("Referer")
		if strings.Contains(referer, "evil") { // blacklist approach
			res.Status(403)
			return false
		}
		// Blacklists are never complete, and attackers can strip Referer headers
		return true
	})
}

// ✓ Solution: whitelist allowed domains, not blacklist forbidden ones

// ────────────────────────────────────────────────────────────────────────────

// ✗ Anti-pattern 5: relying on Content-Type: application/json alone
// Some browsers allow forms to send JSON-like content
// And some servers accept requests without Content-Type checks
// Content-Type isn't a reliable CSRF protection mechanism

CSRF Protection Checklist #

ALL APPLICATIONS:
  □ No state-changing operations via the GET method
  □ Session cookies use SameSite=Lax or Strict
  □ Session cookies use the HttpOnly and Secure flags

APPLICATIONS WITH HTML FORMS:
  □ All data-modifying forms include hidden CSRF tokens
  □ The server validates CSRF tokens with hmac.compare_digest (timing-safe)
  □ CSRF tokens unique per session, generated with CSPRNGs
  □ Tokens regenerated after login

APPLICATIONS WITH AJAX/APIs:
  □ CSRF tokens sent via headers (X-CSRFToken) for modifying requests
  □ Or the Double Submit Cookie pattern used
  □ Origin/Referer headers validated as an additional layer

SPAs:
  □ If using cookie auth: SameSite=Lax minimum + CSRF tokens for sensitive operations
  □ If using JWTs in Authorization headers: no CSRF tokens needed
  □ Interceptors configured to add CSRF headers automatically

HIGHLY SENSITIVE OPERATIONS:
  □ Password re-confirmation for destructive operations (account deletion)
  □ Rate limiting on sensitive endpoints
  □ Email notifications for major operations (large transfers, email changes)

Summary #

  • CSRF exploits the server’s trust in browser cookies — browsers automatically send cookies to matching domains, including when requests are triggered from other websites. Attackers don’t need passwords or stolen cookies.
  • CORS doesn’t protect against CSRF — CORS prevents JavaScript from reading cross-origin responses, but doesn’t prevent requests from being sent. Regular HTML forms aren’t subject to CORS at all.
  • State-changing operations must use POST/PUT/PATCH/DELETE — GET must not modify data. This eliminates one CSRF vector (img tags, link redirects) at the same time.
  • The Synchronizer Token Pattern is the strongest protection — random per-session tokens verified server-side, unguessable by attackers from other domains because of the Same-Origin Policy.
  • SameSite=Lax is the easiest CSRF defense layer to apply — no application logic changes needed, just add the attribute to session cookies. This should be the minimum standard.
  • Use hmac.compare_digest for token comparisons — prevents timing attacks where attackers guess tokens character by character based on response time differences.
  • Double Submit Cookies are useful for stateless architectures — the token in the cookie and header must match. Attackers can’t read cookie values from other domains to send as headers.
  • SPAs with JWTs in Authorization headers are naturally more CSRF-safe — custom headers can’t be added by regular HTML forms. But SPAs with cookie auth still need CSRF protection.
  • Destructive operations need additional confirmation layers — account deletion, large transfers, and email/password changes should require password re-confirmation, not just CSRF tokens.
  • Defense in depth: combine SameSite + CSRF tokens + Origin validation — no single mechanism is perfect alone. The combination of all three provides very strong protection.
#

← Previous: Session Hijacking   Next: Authentication

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact