Skip to content

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

settings.py
# 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',
]
views.py
# 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

settings.py
# 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.
views.py
# 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() calls get_random_string(32, allowed_chars=<62 alphanumerics>), which draws each character with secrets.choice - about 190 bits, not the 256 a 32-byte token would carry, and not secrets.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

views.py
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-CSRFToken header, 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-CSRFToken even if cookies are sent
  • Cookie distribution: Django sets csrftoken cookie 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: CSRFProtect validates 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: FlaskForm adds the csrf_token field, which is what {{ form.csrf_token }} renders. Subclassing wtforms.Form instead produces a form with no token and no error - the field simply renders as nothing, and only CSRFProtect catches the request. For a plain wtforms.Form, render {{ csrf_token() }} explicitly instead
  • AJAX support: the <meta> tag carries the token to JavaScript, which sends it in the X-CSRFToken header
  • Defense-in-depth: SESSION_COOKIE_SAMESITE blocks 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: CSRFProtectMiddleware does not validate anything - its __call__ attaches request.state.csrf_config and passes the request on. @csrf_protect is 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 with X-CSRF-Token 200, 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 with csrf_secret and carries a timestamp, checked against csrf_time_limit (3600 seconds by default). The signature is what makes the token unforgeable, so it cannot be replaced by a random value from secrets.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
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=False for JavaScript access, secure=True for 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

settings.py
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_exempt left 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_SAMESITE but forgetting CSRF_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 ways Strict/Lax was 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 CSRFProtectMiddleware reads as turning protection on and does not - it only attaches configuration, and @csrf_protect is 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-written Depends(...) verifier. Neither is opt-out-by-default the way Django's middleware and Flask-WTF's CSRFProtect are, so the enforcement point has to be added per route and is the thing to grep for when auditing.

Additional Resources