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. Python frameworks like Django and Flask provide built-in CSRF protection, but it must be explicitly enabled and properly configured.

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, enabling unauthorized fund transfers, account changes, or data modifications without user consent.

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 modify user profiles, perform transactions, or delete data using the victim's 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 = True
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: Generates 256-bit tokens using secrets.token_bytes(32) with constant-time comparison to prevent brute-force and timing attacks
  • 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 - complete protection with minimal code
  • 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
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"""
    item_id = request.POST.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

from flask import Flask, render_template, request, session, redirect
from flask_wtf.csrf import CSRFProtect
from flask_sqlalchemy import SQLAlchemy
from wtforms import Form, StringField, validators

app = Flask(__name__)
app.config['SECRET_KEY'] = 'cryptographically-random-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(Form):
    """Form with CSRF protection built-in"""
    email = StringField('Email', [validators.Email()])

@app.route('/change-email', methods=['GET', 'POST'])
def change_email():
    """CSRF protection automatic with Flask-WTF"""
    form = ChangeEmailForm(request.form)

    if request.method == 'POST' and form.validate():
        user = User.query.get(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
"""
<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 encrypted session cookies, bound to authenticated users
  • Zero-configuration forms: {{ form.csrf_token }} auto-includes tokens in WTForms; <meta> tag pattern supports AJAX via 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 protection while maintaining Flask's minimalist approach

FastAPI with CSRF protection

from fastapi import FastAPI, Request, HTTPException, Depends, Cookie, Header
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
from starlette_wtf import CSRFProtectMiddleware, csrf_protect
import secrets

app = FastAPI()

# Add session middleware
app.add_middleware(
    SessionMiddleware,
    secret_key=secrets.token_urlsafe(32),
    same_site='strict',
    https_only=True
)

# Add CSRF protection middleware
app.add_middleware(CSRFProtectMiddleware, csrf_secret=secrets.token_urlsafe(32))

templates = Jinja2Templates(directory="templates")

def verify_csrf_token(
    csrf_token: str = Header(None, alias="X-CSRF-Token"),
    request: Request = None
):
    """Verify CSRF token for API requests"""
    session_token = request.session.get('csrf_token')
    if not csrf_token or csrf_token != session_token:
        raise HTTPException(status_code=403, detail="CSRF token invalid")
    return csrf_token

@app.get("/change-email", response_class=HTMLResponse)
async def change_email_form(request: Request):
    """Render form with CSRF token"""
    # Generate CSRF token if not exists
    if 'csrf_token' not in request.session:
        request.session['csrf_token'] = secrets.token_urlsafe(32)

    return templates.TemplateResponse(
        "change_email.html",
        {"request": request, "csrf_token": request.session['csrf_token']}
    )

@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")
async def delete_item(
    request: Request,
    csrf_token: str = Depends(verify_csrf_token)
):
    """API endpoint with explicit CSRF verification"""
    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:

  • ASGI middleware protection: CSRFProtectMiddleware validates tokens before endpoints execute, rejecting invalid requests with HTTP 403
  • Flexible validation: Middleware provides global protection, @csrf_protect decorator for forms, dependency injection for custom API logic
  • Cryptographic tokens: secrets.token_urlsafe(32) generates 256-bit tokens stored in session cookies with same_site='strict' and https_only=True
  • Async-friendly: Non-blocking validation suitable for high-concurrency applications without performance overhead
  • 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
import hmac
import hashlib
import secrets

app = Flask(__name__)
app.config['SECRET_KEY'] = secrets.token_urlsafe(32)

def generate_csrf_token():
    """Generate CSRF token"""
    return secrets.token_urlsafe(32)

def create_csrf_signature(token):
    """Create HMAC signature of token"""
    return hmac.new(
        app.config['SECRET_KEY'].encode(),
        token.encode(),
        hashlib.sha256
    ).hexdigest()

@app.route('/api/get-csrf-token')
def get_csrf_token():
    """Provide CSRF token to client"""
    token = generate_csrf_token()
    signature = create_csrf_signature(token)

    # 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 signature
    expected_signature = create_csrf_signature(cookie_token)
    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:

  • Stateless scaling: No server-side session storage required - tokens stored only in cookies enable horizontal scaling without session affinity
  • HMAC signature security: secrets.token_urlsafe(32) generates 256-bit tokens signed with HMAC-SHA256 using SECRET_KEY, preventing forgery without the secret
  • 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) across all architectures
  • 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'}

Verification

After implementing the recommended secure patterns, verify the fix through multiple approaches:

  • Manual testing: Submit malicious payloads relevant to this vulnerability and confirm they're handled safely without executing unintended operations
  • Code review: Confirm all instances use the secure pattern (parameterized queries, safe APIs, proper encoding) with no string concatenation or unsafe operations
  • Static analysis: Use security scanners to verify no new vulnerabilities exist and the original finding is resolved
  • Regression testing: Ensure legitimate user inputs and application workflows continue to function correctly
  • Edge case validation: Test with special characters, boundary conditions, and unusual inputs to verify proper handling
  • Framework verification: If using a framework or library, confirm the recommended APIs are used correctly according to documentation
  • Authentication/session testing: Verify security controls remain effective and cannot be bypassed (if applicable to the vulnerability type)
  • Rescan: Run the security scanner again to confirm the finding is resolved and no new issues were introduced

Additional Resources