CSRF Protection: Complete Developer Guide
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 respExpected 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
| Value | Cross-site GET | Cross-site POST | Use Case |
|---|---|---|---|
Strict | Blocked | Blocked | Banking, email |
Lax | Sent (top-level) | Blocked | Most web apps (default) |
None | Sent | Sent | Third-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 responseWhy 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
| Aspect | CORS | CSRF Protection |
|---|---|---|
| Controls | Reading responses | Writing state changes |
| Mechanism | Browser-enforced (headers) | Token verification or SameSite |
| Affected by HTML forms | No | Yes — 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
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