CWE-352: Cross-Site Request Forgery (CSRF) - Python
Overview
CSRF vulnerabilities in Python web applications occur when state-changing endpoints don't validate that requests originated from the application itself. Django ships it enabled - CsrfViewMiddleware is in the startproject template - so a finding against a Django application usually means the middleware was removed or the view was exempted. Flask has no built-in equivalent and needs Flask-WTF or a check written by hand.
Primary Defence: Enable Django's CsrfViewMiddleware and use {% csrf_token %} in forms, or use Flask-WTF with CSRFProtect for automatic token validation.
Common Vulnerable Patterns
Django with CSRF protection disabled
# VULNERABLE
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
# 'django.middleware.csrf.CsrfViewMiddleware', # CSRF protection disabled!
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
]
# VULNERABLE
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
@csrf_exempt # Disables CSRF protection for this view
def transfer_funds(request):
if request.method == 'POST':
amount = request.POST.get('amount')
to_account = request.POST.get('to_account')
# Transfer money without CSRF validation
perform_transfer(request.user, to_account, amount)
return HttpResponse('Transfer complete')
Why this is vulnerable: Disabling Django's CsrfViewMiddleware or using @csrf_exempt allows attackers to create malicious websites that submit authenticated POST requests using the victim's session cookie, moving money out of the victim's account without their knowledge.
Flask without CSRF protection
from flask import Flask, request, session, redirect
from flask_sqlalchemy import SQLAlchemy
app = Flask(__name__)
app.config['SECRET_KEY'] = 'secret'
db = SQLAlchemy(app)
# VULNERABLE - No CSRF protection configured
@app.route('/change-email', methods=['POST'])
def change_email():
if 'user_id' not in session:
return redirect('/login')
new_email = request.form['email']
user = User.query.get(session['user_id'])
user.email = new_email # Changes made without CSRF validation
db.session.commit()
return 'Email updated'
@app.route('/delete-account', methods=['POST'])
def delete_account():
# VULNERABLE - Critical action without CSRF protection
if 'user_id' in session:
user = User.query.get(session['user_id'])
db.session.delete(user)
db.session.commit()
return 'Account deleted'
Why this is vulnerable: Flask has no built-in CSRF protection - without Flask-WTF or custom token validation, attackers can craft forms on external sites that POST to these endpoints using victims' authenticated sessions, silently changing emails or deleting accounts.
FastAPI without CSRF protection
from fastapi import FastAPI, Depends, Cookie
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
app = FastAPI()
# VULNERABLE - State changes without CSRF tokens
@app.post("/api/update-profile")
async def update_profile(
email: str,
session_id: str = Cookie(None),
db: Session = Depends(get_db)
):
# Relies only on cookie authentication - vulnerable to CSRF
user = get_user_by_session(session_id, db)
if user:
user.email = email
db.commit()
return {"status": "updated"}
return JSONResponse({"error": "Unauthorized"}, status_code=401)
Why this is vulnerable: FastAPI relies only on cookie authentication without CSRF token validation, allowing attackers to submit cross-origin requests from malicious websites that change the victim's profile using their authenticated session.
Secure Patterns
Django with CSRF protection enabled
# SECURE
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', # CSRF protection enabled
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
# Configure secure session cookies
SESSION_COOKIE_SAMESITE = 'Strict'
SESSION_COOKIE_SECURE = True # HTTPS only
SESSION_COOKIE_HTTPONLY = True
CSRF_COOKIE_SAMESITE = 'Strict'
CSRF_COOKIE_SECURE = True
# CSRF_COOKIE_HTTPONLY is deliberately left at its default of False.
# Django's docs say it "doesn't offer any practical protection because CSRF is
# only to protect against cross-domain attacks", and setting it breaks the
# AJAX pattern below, which reads the token out of the cookie.
# SECURE
from django.shortcuts import render, redirect
from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_http_methods
@login_required
@require_http_methods(["POST"])
def transfer_funds(request):
"""CSRF token automatically validated by middleware"""
amount = request.POST.get('amount')
to_account = request.POST.get('to_account')
# Additional validation
if not amount or not to_account:
return render(request, 'error.html', {'message': 'Invalid input'})
# CSRF token already validated by CsrfViewMiddleware
perform_transfer(request.user, to_account, amount)
return redirect('transfer_success')
# Template with CSRF token
"""
<form method="post" action="{% url 'transfer_funds' %}">
{% csrf_token %}
<input type="text" name="to_account" required>
<input type="number" name="amount" required>
<button type="submit">Transfer</button>
</form>
"""
Why this works:
- Automatic global protection: Validates all POST/PUT/PATCH/DELETE requests without decorators, preventing developers from forgetting CSRF tokens on new endpoints
- Cryptographically strong tokens:
_get_new_csrf_string()callsget_random_string(32, allowed_chars=<62 alphanumerics>), which draws each character withsecrets.choice- about 190 bits, not the 256 a 32-byte token would carry, and notsecrets.token_bytes. The secret is then masked per response, so the value in the form differs from the one in the cookie on every render while validating against the same secret. Comparison is constant-time - Defense-in-depth:
SESSION_COOKIE_SAMESITE = 'Strict'blocks cross-site cookie transmission, preventing attacks before token validation occurs - Zero-configuration:
{% csrf_token %}template tag auto-injects tokens; middleware validates automatically - Salted tokens: Each page render generates unique token from cookie secret, allowing multiple tabs while validating against the same session
Django AJAX with CSRF token
import json
from django.http import JsonResponse
from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_POST
@login_required
@require_POST
def api_delete_item(request):
"""API endpoint with CSRF protection"""
# request.POST is populated only for form-encoded and multipart bodies.
# The fetch below sends Content-Type: application/json, for which
# request.POST is empty - so read the raw body. Left as request.POST,
# item_id is None and the endpoint answers 404 to every valid request
try:
payload = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({'error': 'Invalid JSON'}, status=400)
item_id = payload.get('item_id')
try:
item = Item.objects.get(id=item_id, user=request.user)
item.delete()
return JsonResponse({'status': 'deleted'})
except Item.DoesNotExist:
return JsonResponse({'error': 'Not found'}, status=404)
# JavaScript for AJAX requests
"""
// Get CSRF token from cookie
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
const csrftoken = getCookie('csrftoken');
// Configure fetch to include CSRF token
fetch('/api/delete-item', {
method: 'POST',
headers: {
'X-CSRFToken': csrftoken,
'Content-Type': 'application/json'
},
body: JSON.stringify({item_id: 123})
})
.then(response => response.json())
.then(data => console.log(data));
"""
Why this works:
- AJAX-friendly validation: Accepts tokens in
X-CSRFTokenheader, enabling JavaScript apps without modifying request bodies - Same-origin enforcement: Browsers won't send custom headers in cross-site requests, so attackers cannot include required
X-CSRFTokeneven if cookies are sent - Cookie distribution: Django sets
csrftokencookie automatically; JavaScript reads it and includes in header for all API calls - Flexible validation: Middleware checks both POST parameter (traditional forms) and header (AJAX), supporting hybrid architectures
- XSS dependency: Requires XSS prevention since injected JavaScript can read the cookie and make authenticated requests
Flask with Flask-WTF CSRF protection
import os
from flask import Flask, render_template, request, session, redirect
from flask_wtf import FlaskForm
from flask_wtf.csrf import CSRFProtect
from flask_sqlalchemy import SQLAlchemy
from wtforms import StringField, validators
app = Flask(__name__)
# Read the key from the environment. It must be stable across restarts and
# identical in every worker, or sessions and tokens break unpredictably.
app.config['SECRET_KEY'] = os.environ['FLASK_SECRET_KEY']
app.config['WTF_CSRF_ENABLED'] = True
app.config['WTF_CSRF_TIME_LIMIT'] = None # Or set to seconds (e.g., 3600)
# Enable CSRF protection
csrf = CSRFProtect(app)
db = SQLAlchemy(app)
# Configure secure cookies
app.config.update(
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE='Strict'
)
class ChangeEmailForm(FlaskForm):
"""FlaskForm carries the csrf_token field; a plain wtforms.Form does not"""
email = StringField('Email', [validators.Email()])
@app.route('/change-email', methods=['GET', 'POST'])
def change_email():
"""CSRF protection automatic with Flask-WTF"""
form = ChangeEmailForm()
if form.validate_on_submit():
user = db.session.get(User, session['user_id'])
user.email = form.email.data
db.session.commit()
return redirect('/profile')
return render_template('change_email.html', form=form)
# Template with CSRF token
"""
<head>
<!-- The AJAX path below reads this tag. Without it,
querySelector returns null and the fetch never runs.
csrf_meta_tag() is a Jinja global registered by CSRFProtect
and renders <meta name="csrf-token" content="..."> -->
{{ csrf_meta_tag() }}
</head>
<form method="post">
{{ form.csrf_token }}
{{ form.email.label }}: {{ form.email }}
<button type="submit">Update</button>
</form>
"""
@app.route('/api/delete-item', methods=['POST'])
def api_delete_item():
"""API endpoint with CSRF protection"""
# CSRF token validated automatically by CSRFProtect
item_id = request.json.get('item_id')
if 'user_id' not in session:
return {'error': 'Unauthorized'}, 401
item = Item.query.filter_by(id=item_id, user_id=session['user_id']).first()
if item:
db.session.delete(item)
db.session.commit()
return {'status': 'deleted'}
return {'error': 'Not found'}, 404
# JavaScript for AJAX with CSRF
"""
<script>
// CSRF token in meta tag
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
fetch('/api/delete-item', {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,
'Content-Type': 'application/json'
},
body: JSON.stringify({item_id: 123})
});
</script>
"""
Why this works:
- Automatic validation:
CSRFProtectvalidates all POST/PUT/PATCH/DELETE requests globally without decorators, following secure-by-default philosophy - Cryptographic tokens: Generates strong tokens using
os.urandom()stored in signed session cookies, bound to authenticated users - The base class is what carries the token:
FlaskFormadds thecsrf_tokenfield, which is what{{ form.csrf_token }}renders. Subclassingwtforms.Forminstead produces a form with no token and no error - the field simply renders as nothing, and onlyCSRFProtectcatches the request. For a plainwtforms.Form, render{{ csrf_token() }}explicitly instead - AJAX support: the
<meta>tag carries the token to JavaScript, which sends it in theX-CSRFTokenheader - Defense-in-depth:
SESSION_COOKIE_SAMESITEblocks cross-site cookies; constant-time comparison prevents timing attacks; early validation rejects invalid requests before route handlers - Flask ecosystem fit: Canonical extension providing Django-style automatic protection
FastAPI with CSRF protection
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
from starlette_wtf import CSRFProtectMiddleware, csrf_protect, csrf_token
import os
app = FastAPI()
# Both secrets come from the environment. Generating them at import time with
# secrets.token_urlsafe() looks strong and fails in production: the value
# changes on every restart, and each worker in a multi-process deployment gets
# a different one, so sessions and tokens are rejected at random.
app.add_middleware(
SessionMiddleware,
secret_key=os.environ['SESSION_SECRET'],
same_site='strict',
https_only=True
)
# Add CSRF protection middleware
app.add_middleware(CSRFProtectMiddleware, csrf_secret=os.environ['CSRF_SECRET'])
templates = Jinja2Templates(directory="templates")
@app.get("/change-email", response_class=HTMLResponse)
async def change_email_form(request: Request):
"""Render form with CSRF token"""
# csrf_token(request) returns the *signed* token that @csrf_protect
# validates. Minting one with secrets.token_urlsafe() and storing it in
# the session instead produces a value the decorator rejects, so every
# genuine submission answers 403 - the two halves have to use the same
# mechanism, and this one is the mechanism the middleware installed
token = csrf_token(request)
# Starlette takes the request first. The older
# TemplateResponse(name, {"request": request, ...}) form was deprecated in
# Starlette 0.29 and removed in 1.0 - on a current release it binds the
# name to `request` and raises TypeError: unhashable type: 'dict'
return templates.TemplateResponse(
request, "change_email.html", {"csrf_token": token}
)
# templates/change_email.html - the hidden field is what @csrf_protect reads
# on a normal form POST. Passing the token to the template is not enough;
# without the field the decorator finds no token and answers 403
"""
<head>
<!-- Only needed if this page also makes AJAX calls -->
<meta name="csrf-token" content="{{ csrf_token }}">
</head>
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input name="email" type="email">
<button type="submit">Update</button>
</form>
"""
@app.post("/change-email")
@csrf_protect
async def change_email(request: Request):
"""Handle form submission with CSRF validation"""
form_data = await request.form()
# CSRF validated by @csrf_protect decorator
user_id = request.session.get('user_id')
if not user_id:
raise HTTPException(status_code=401, detail="Unauthorized")
new_email = form_data.get('email')
# Update email in database
update_user_email(user_id, new_email)
return JSONResponse({"status": "updated"})
@app.post("/api/delete-item")
@csrf_protect
async def delete_item(request: Request):
"""API endpoint - same decorator, no separate verification path
@csrf_protect reads the token from the form field or from either of
the csrf_headers the middleware is configured with, which default to
X-CSRFToken and X-CSRF-Token. A hand-written dependency comparing the
header against a session value is a second mechanism to keep in step
with this one, and compares with != rather than in constant time
"""
data = await request.json()
item_id = data.get('item_id')
user_id = request.session.get('user_id')
if not user_id:
raise HTTPException(status_code=401, detail="Unauthorized")
# Delete item
delete_user_item(user_id, item_id)
return {"status": "deleted"}
def update_user_email(user_id, email):
pass
def delete_user_item(user_id, item_id):
pass
Why this works:
- The middleware configures, the decorator enforces:
CSRFProtectMiddlewaredoes not validate anything - its__call__attachesrequest.state.csrf_configand passes the request on.@csrf_protectis what checks the token and raises 403, so an endpoint without it is unprotected however the middleware is configured. Verified on starlette-wtf 0.5.0: form with token 200, without 403, with an unsigned token 403; AJAX withX-CSRF-Token200, without 403 - One mechanism, both entry points: the form field and the AJAX header are read by the same decorator, so there is no second validation path to keep in step
- Signed, expiring tokens:
csrf_token(request)signs withcsrf_secretand carries a timestamp, checked againstcsrf_time_limit(3600 seconds by default). The signature is what makes the token unforgeable, so it cannot be replaced by a random value fromsecrets.token_urlsafe(), which carries no signature for the decorator to check - Async-friendly: Non-blocking validation suitable for high-concurrency applications
- Explicit configuration: Requires manual setup (middleware + decorators) reflecting FastAPI's explicit-over-implicit philosophy, unlike Django's automatic protection
Double Submit Cookie Pattern (Stateless CSRF)
from flask import Flask, request, make_response, jsonify, session
import hmac
import hashlib
import os
import secrets
app = Flask(__name__)
app.config['SECRET_KEY'] = os.environ['FLASK_SECRET_KEY']
def generate_csrf_token():
"""Generate CSRF token"""
return secrets.token_urlsafe(32)
def create_csrf_signature(token, session_id):
"""HMAC the token together with the session it belongs to.
Signing the token alone is not enough: any valid signed token would then
verify for any user, which is what an attacker who can write a cookie on a
sibling subdomain needs. Binding to the session is what makes an injected
cookie useless.
"""
message = f"{len(session_id)}!{session_id}!{len(token)}!{token}"
return hmac.new(
app.config['SECRET_KEY'].encode(),
message.encode(),
hashlib.sha256
).hexdigest()
@app.route('/api/get-csrf-token')
def get_csrf_token():
"""Provide CSRF token to client"""
session_id = session.get('id')
if not session_id:
return jsonify({'error': 'No session'}), 401
token = generate_csrf_token()
signature = create_csrf_signature(token, session_id)
# Set signed token in cookie
response = make_response(jsonify({'csrf_token': token}))
response.set_cookie(
'csrf_token',
f'{token}.{signature}',
secure=True,
httponly=False, # JavaScript needs to read this
samesite='Strict'
)
return response
@app.route('/api/protected-action', methods=['POST'])
def protected_action():
"""Verify double submit cookie"""
# Get token from header
header_token = request.headers.get('X-CSRF-Token')
# Get token from cookie
cookie_value = request.cookies.get('csrf_token')
if not cookie_value or '.' not in cookie_value:
return jsonify({'error': 'CSRF token missing'}), 403
cookie_token, signature = cookie_value.rsplit('.', 1)
# Verify the signature against THIS session, so a token minted for another
# session (or injected by a sibling subdomain) fails here
session_id = session.get('id')
if not session_id:
return jsonify({'error': 'No session'}), 401
expected_signature = create_csrf_signature(cookie_token, session_id)
if not hmac.compare_digest(signature, expected_signature):
return jsonify({'error': 'CSRF token invalid'}), 403
# Verify tokens match
if not header_token or not hmac.compare_digest(header_token, cookie_token):
return jsonify({'error': 'CSRF token mismatch'}), 403
# Process request
return jsonify({'status': 'success'})
Why this works:
- Session binding is the load-bearing part: the HMAC covers the session identifier as well as the token, so a signed token is only valid for the session it was issued to. OWASP is explicit that "simply signing tokens without session binding provides minimal protection and remains vulnerable to cookie injection attacks" - the threat being an attacker who can write a cookie on the target domain from a sibling subdomain, a DNS takeover, or plaintext HTTP. Signing alone stops them forging a token; only session binding stops them replaying a legitimate one of their own
- Length-prefixed HMAC input:
f"{len(session_id)}!{session_id}!{len(token)}!{token}"keeps the two fields unambiguous, so no pair of different values can produce the same signed message - Stateless scaling: No server-side token store required - the cookie carries the token and the signature proves it was issued here
- Dual validation: Verifies HMAC signature using
hmac.compare_digest()(constant-time) and matches cookie/header values - attackers can't read cookies (same-origin) or set custom headers cross-site - Cookie security:
httponly=Falsefor JavaScript access,secure=Truefor HTTPS-only,samesite='Strict'for cross-site blocking - Hybrid support: Works for forms (token in hidden field) and AJAX (token in header)
- Tradeoffs: Tokens don't auto-expire on logout; requires lifecycle management via timestamps and periodic secret rotation
Framework-Specific Guidance
Django REST Framework CSRF
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.SessionAuthentication',
],
# CSRF protection enabled by default for SessionAuthentication
}
# views.py
from rest_framework.decorators import api_view
from rest_framework.response import Response
from rest_framework import status
@api_view(['POST'])
def delete_item(request):
"""DRF view with automatic CSRF protection"""
# CSRF validated automatically when using SessionAuthentication
item_id = request.data.get('item_id')
try:
item = Item.objects.get(id=item_id, user=request.user)
item.delete()
return Response({'status': 'deleted'})
except Item.DoesNotExist:
return Response({'error': 'Not found'}, status=status.HTTP_404_NOT_FOUND)
Pyramid CSRF
from pyramid.config import Configurator
from pyramid.csrf import new_csrf_token
from pyramid.view import view_config
def main(global_config, **settings):
config = Configurator(settings=settings)
# Enable CSRF protection
config.set_default_csrf_options(require_csrf=True)
config.scan()
return config.make_wsgi_app()
@view_config(route_name='transfer', request_method='POST', require_csrf=True)
def transfer_funds(request):
"""CSRF protection required"""
# Token validated automatically
amount = request.POST['amount']
to_account = request.POST['to_account']
perform_transfer(request.user, to_account, amount)
return {'status': 'success'}
Common Pitfalls
@csrf_exemptleft in place after the "real" fix landed elsewhere: Django's decorator is meant only for endpoints with an alternative verification mechanism (signed webhooks, API-key auth). It's commonly added to silence a 403 during development, and then never removed once the actual fix (adding{% csrf_token %}to the form) is made somewhere else, leaving the endpoint permanently unprotected.- Hardening
SESSION_COOKIE_SAMESITEbut forgettingCSRF_COOKIE_SAMESITE: Django manages the session cookie and the CSRF cookie as separate settings with separate defaults. Locking down one and forgetting the other still leaves the CSRF cookie sendable cross-site in the waysStrict/Laxwas meant to prevent. - A FastAPI CSRF check wired into only the routes that existed when it was added: FastAPI has no built-in, app-wide CSRF protection. Installing
CSRFProtectMiddlewarereads as turning protection on and does not - it only attaches configuration, and@csrf_protectis what validates - so a POST/PUT/DELETE endpoint added later without the decorator is unprotected while the app still looks configured. The same applies to a hand-writtenDepends(...)verifier. Neither is opt-out-by-default the way Django's middleware and Flask-WTF'sCSRFProtectare, so the enforcement point has to be added per route and is the thing to grep for when auditing.
Additional Resources
- CWE-352: Cross-Site Request Forgery
- Django CSRF Protection
- Flask-WTF CSRF Protection -
csrf_meta_tag()used above needs Flask-WTF 1.3 or later; on 1.2 render<meta name="csrf-token" content="{{ csrf_token() }}">by hand - OWASP CSRF Prevention Cheat Sheet