Skip to content
CSRF Protection: Complete Developer Guide

CSRF Protection: Complete Developer Guide

DodaTech Updated Jun 20, 2026 10 min read

Cross-Site Request Forgery (CSRF) is an attack that forces an authenticated user to execute unwanted actions on a web application where they’re currently logged in — exploiting the browser’s automatic cookie inclusion.

What You’ll Learn

You’ll understand how CSRF attacks work, implement SameSite cookies for modern protection, generate and validate CSRF tokens, apply the Double Submit Cookie and Synchronizer Token patterns, leverage framework protections (Spring, Django, Rails), and understand the critical difference between CORS and CSRF.

Why It Matters

CSRF is in the OWASP Top 10 and remains one of the most common web vulnerabilities. Modern frameworks include built-in CSRF protection, but misconfiguration — especially CORS misconfigurations — can bypass it entirely. Durga Antivirus Pro’s web scanning module tests for CSRF vulnerabilities by verifying SameSite and anti-CSRF token patterns in forms.

Real-World Use

A user is logged into their banking site. Without logging out, they visit a malicious forum. The forum contains an invisible form that submits a transfer to the attacker’s account. The bank sees a legitimate request from the authenticated user — and transfers the money. This is CSRF.

CSRF Attack Flow


flowchart LR
    A[User logs into bank] --> B[Session cookie set]
    B --> C[User visits attacker's site]
    C --> D[Attacker's page has auto-submit form]
    D --> E[Form POSTs to bank.com/transfer]
    E --> F[Browser auto-attaches session cookie]
    F --> G[Bank processes transfer]
    G --> H[Money sent to attacker]
    
    style A fill:#059669,color:#fff
    style D fill:#dc2626,color:#fff
    style H fill:#dc2626,color:#fff

Step 1: SameSite Cookies — First Line of Defense

The SameSite attribute on cookies tells the browser when to send cookies:

from flask import Flask, make_response

app = Flask(__name__)

@app.route("/login", methods=["POST"])
def login():
    resp = make_response("Logged in")
    # SameSite=Strict: Cookie only sent for same-site requests
    # Best for banking apps, but breaks SSO flows
    resp.set_cookie("session", "token123",
                    samesite="Strict",
                    secure=True, httponly=True)
    return resp

@app.route("/login_lax")
def login_lax():
    resp = make_response("Logged in (Lax)")
    # SameSite=Lax: Cookie sent for top-level GET navigations
    # Good balance — works for most sites, blocks CSRF on POST
    resp.set_cookie("session", "token123",
                    samesite="Lax",
                    secure=True, httponly=True)
    return resp

@app.route("/login_none")
def login_none():
    resp = make_response("Logged in (None)")
    # SameSite=None: Cookie sent for all requests (cross-site too)
    # Requires Secure=True. Only use when absolutely needed
    resp.set_cookie("session", "token123",
                    samesite="None",
                    secure=True, httponly=True)
    return resp

Expected output: With SameSite=Strict, the bank cookie is NEVER sent when the attacker’s site submits the form — the request appears unauthenticated, and the bank rejects it. With SameSite=Lax, top-level GET navigations (clicking a link) send the cookie, but POST requests (form submissions) do not — blocking the CSRF attack.

SameSite Comparison

ValueCross-site GETCross-site POSTUse Case
StrictBlockedBlockedBanking, email
LaxSent (top-level)BlockedMost web apps (default)
NoneSentSentThird-party widgets, SSO

Step 2: Synchronizer Token Pattern

The server generates a unique token per session and requires it in every state-changing request:

import secrets
from flask import Flask, request, session, render_template_string

app = Flask(__name__)
app.secret_key = secrets.token_hex(32)

@app.route("/transfer", methods=["GET"])
def show_transfer():
    # Generate CSRF token and store in session
    if "csrf_token" not in session:
        session["csrf_token"] = secrets.token_hex(32)

    html = f"""
    <form method="POST" action="/transfer">
        <input type="hidden" name="csrf_token" value="{session['csrf_token']}">
        <label>To Account: <input name="to_account"></label>
        <label>Amount: <input name="amount" type="number"></label>
        <button type="submit">Transfer</button>
    </form>
    """
    return render_template_string(html)

@app.route("/transfer", methods=["POST"])
def process_transfer():
    token = request.form.get("csrf_token", "")

    # SECURE: Compare submitted token with session token
    if not token or token != session.get("csrf_token"):
        return "CSRF token invalid! Request blocked.", 403

    to_account = request.form["to_account"]
    amount = request.form["amount"]
    return f"Transferred ${amount} to account {to_account}"

Expected output: The form includes a hidden csrf_token that matches the session value. The attacker’s page cannot guess this token because it’s a cryptographically random 32-byte hex string (2^256 possibilities). The server rejects the forged request because the token is missing or wrong.

Step 3: Double Submit Cookie Pattern

The Double Submit Cookie pattern doesn’t require server-side token storage — the same token is set as a cookie AND included in the request body:

import secrets
from flask import Flask, request, make_response, render_template_string

app = Flask(__name__)

@app.route("/transfer", methods=["GET"])
def show_transfer():
    # Generate CSRF token and set as cookie
    csrf_token = secrets.token_hex(32)
    resp = make_response(f"""
    <form method="POST" action="/transfer">
        <input type="hidden" name="csrf_token" value="{csrf_token}">
        <label>To Account: <input name="to_account"></label>
        <label>Amount: <input name="amount" type="number"></label>
        <button type="submit">Transfer</button>
    </form>
    """)
    resp.set_cookie("csrf_token", csrf_token, samesite="Strict", secure=True, httponly=True)
    return resp

@app.route("/transfer", methods=["POST"])
def process_transfer():
    # Compare cookie token with form token
    cookie_token = request.cookies.get("csrf_token", "")
    form_token = request.form.get("csrf_token", "")

    if not cookie_token or not form_token:
        return "Missing CSRF tokens", 403

    # SECURE: Constant-time comparison to prevent timing attacks
    if len(cookie_token) != len(form_token):
        return "CSRF token mismatch", 403

    result = 0
    for a, b in zip(cookie_token, form_token):
        result |= ord(a) ^ ord(b)
    if result != 0:
        return "CSRF token mismatch", 403

    return f"Transferred ${request.form['amount']} to {request.form['to_account']}"

Expected output: The attacker’s page cannot read the csrf_token cookie (set httponly=True to protect from XSS) and cannot guess the matching token value. Since the cookie is set with SameSite=Strict, it’s not even sent with cross-site requests — defeating the attacker’s forged form.

Step 4: Framework Protection (Spring, Django, Rails)

Django

# settings.py — CSRF is ENABLED by default in Django
MIDDLEWARE = [
    'django.middleware.csrf.CsrfViewMiddleware',  # This is key
    # ...
]

# In templates, always use the csrf_token tag
"""
<form method="post">
    {% csrf_token %}
    <input name="amount">
    <button>Submit</button>
</form>
"""

# For AJAX — read token from cookie and send in header
const csrftoken = document.cookie
  .split('; ')
  .find(row => row.startsWith('csrftoken='))
  ?.split('=')[1];

fetch('/api/transfer', {
  method: 'POST',
  headers: {
    'X-CSRFToken': csrftoken,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ amount: 100 })
});

Spring Boot

// Spring Security CSRF is enabled by default
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
            )
            .authorizeHttpRequests(authz -> authz
                .anyRequest().authenticated()
            );
        return http.build();
    }
}

// In Thymeleaf template — auto-injected
<form th:action="@{/transfer}" method="post">
    <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
    <input name="amount" />
    <button>Submit</button>
</form>

Ruby on Rails

# Rails — CSRF protection enabled by default
# application_controller.rb
class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception  # Raises error on invalid token

  # For API-only apps:
  # protect_from_forgery with: :null_session
end

# In forms — auto-added by form helpers
<%= form_with url: "/transfer" do |form| %>
  <%= form.label :amount %>
  <%= form.number_field :amount %>
  <%= form.submit "Transfer" %>
<% end %>
# Rails auto-adds: <input type="hidden" name="authenticity_token" value="...">

Expected output: Django, Spring, and Rails all include CSRF protection by default. The token is embedded in forms automatically. If you disable CSRF protection (e.g., for API endpoints), you must use a separate authentication mechanism like JWT with Bearer tokens that aren’t automatically attached by browsers.

Step 5: CORS vs CSRF — The Critical Difference

# CORS configuration (DOES NOT prevent CSRF)
from flask import Flask
from flask_cors import CORS

app = Flask(__name__)

# This allows cross-origin requests — but does NOT prevent CSRF
CORS(app, origins=["https://trusted-site.com"])

# CSRF prevention is SEPARATE from CORS
# CORS: "Who can read my responses?"
# CSRF: "Who can send authenticated requests to me?"

@app.after_request
def security_headers(response):
    # CORS headers control reading responses
    response.headers["Access-Control-Allow-Origin"] = "https://trusted-site.com"

    # CSRF protection requires anti-CSRF tokens or SameSite cookies
    # CORS alone is INSUFFICIENT for CSRF protection
    return response

Why CORS doesn’t block CSRF: CORS restricts whether JavaScript can READ the response, not whether the request is SENT. The attacker’s HTML form (<form action="https://bank.com/transfer">) submits a cross-origin POST without JavaScript — CORS does not apply to HTML form submissions. The browser sends the request and cookies regardless.

Key Differences

AspectCORSCSRF Protection
ControlsReading responsesWriting state changes
MechanismBrowser-enforced (headers)Token verification or SameSite
Affected by HTML formsNoYes — forms bypass CORS
Blocked by default?Yes (same-origin policy)No (cookies sent automatically)

Common Errors

1. Disabling CSRF protection for “convenience” Many developers disable CSRF for API endpoints thinking “it’s just an API.” If the API uses cookie-based authentication, it’s vulnerable to CSRF. Use token-based auth (JWT in Authorization header) for APIs — cookies sent by fetch() with credentials: 'include' are still vulnerable.

2. Confusing CORS with CSRF protection “Allow all origins” in CORS does NOT disable CSRF. An attacker can still submit a <form> to your endpoint from any origin — the browser will send the cookie. You need SameSite cookies or CSRF tokens regardless of your CORS policy.

3. Using GET for state-changing operations A logout link: <a href="/logout">Logout</a>. An attacker embeds <img src="https://victim.com/logout"> on any page. When the browser loads the image, it sends a GET request to /logout — logging out the victim. Always use POST for state changes.

4. Not protecting custom headers CSRF tokens in request headers (X-CSRF-Token) are secure, but only if browsers don’t automatically send custom headers. They don’t — which makes header-based CSRF protection strong. However, if you whitelist Access-Control-Allow-Headers: *, you might enable attackers to send custom headers.

5. SameSite=None without Secure Setting SameSite=None without Secure (HTTPS) is rejected by modern browsers. The cookie won’t be set at all, breaking authentication. Always pair SameSite=None with Secure and use HTTPS in production.

Practice Questions

1. How does a CSRF attack work? The attacker creates a form or request targeting a site where the victim is authenticated. The browser automatically attaches the victim’s session cookie. The server sees a legitimate authenticated request and processes it. The victim didn’t intend the action.

2. What is the difference between SameSite=Strict and SameSite=Lax? Strict blocks the cookie on ALL cross-site requests — even when clicking a link. Lax allows top-level GET navigations (clicking a link) but blocks POST, PUT, DELETE, and other mutating methods. Lax is the default in modern browsers and the recommended starting point.

3. How does the Synchronizer Token pattern work? The server generates a random token per session and stores it server-side. Every form includes the token as a hidden field. On submission, the server compares the submitted token with the stored session token. Attackers cannot guess the token (cryptographically random).

4. Why doesn’t CORS prevent CSRF? CORS controls response reading — whether JavaScript can access the response body. HTML form submissions (<form action="...">) send requests without JavaScript and are NOT subject to CORS. The browser sends the request and cookies regardless of CORS.

5. Challenge: Exploit a CSRF vulnerability Set up DVWA with the CSRF challenge. Craft an HTML page (hosted locally) that auto-submits a form to change the victim’s password. Then implement the fix using a CSRF token. Verify the attack fails after the fix.

FAQ

Can CSRF tokens be stolen by XSS?
Yes. If an attacker has XSS on your site, they can read the CSRF token from the DOM or cookies and include it in forged requests. CSRF protection is useless if XSS exists. Fix XSS first, then add CSRF protection.
Is CSRF still a problem with SameSite cookies?
SameSite=Lax (browser default) blocks most CSRF attacks. However, SameSite doesn’t protect against: subdomain takeover (attacker controls subdomain), browser extensions, or older browsers that don’t support SameSite. CSRF tokens remain important defense-in-depth.
What is the difference between CSRF and XSS?
CSRF tricks the user into performing an action they didn’t intend (request forgery). XSS injects scripts that execute in the user’s browser (code injection). CSRF exploits trust in the user’s browser; XSS exploits trust in the user’s input.
Do I need CSRF protection for REST APIs?
Not if you use token-based authentication (JWT in Authorization header, API keys, OAuth Bearer tokens) instead of cookies. Browsers don’t automatically attach Authorization headers — attackers cannot forge authenticated API requests. Use cookies → need CSRF protection.

What’s Next

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro