FastAPI

FastAPI CSRF Protection — Secure Forms & State-Changing Requests

Thirdy Gayares
14 min read

🎓 What You Will Learn

  • CSRF Attacks: How cross-site request forgery works
  • Token Generation: Creating secure CSRF tokens
  • Token Validation: Validating tokens in requests
  • Form Protection: Securing HTML forms against CSRF
  • SameSite Cookies: Modern browser-level protection
  • Testing & Best Practices: Securing your FastAPI app
CSRFSecurityTokensForms

1What is CSRF? The Attack Explained

A Cross-Site Request Forgery (CSRF) attack tricks a user into making unwanted requests to a website where they're authenticated. For example, while logged into your bank, you visit a malicious website that secretly makes a transfer request on your behalf.

Key Insight: Browsers automatically include cookies with requests to the same domain. Attackers exploit this by making requests from different domains.

❌ Vulnerable (No CSRF Protection)

  1. 1. User logs into bank.com
  2. 2. User visits malicious.com in another tab
  3. 3. malicious.com sends request to bank.com/transfer
  4. 4. Browser includes bank.com cookies automatically
  5. 5. Transfer succeeds without user knowing!

2CSRF Token Strategy

The classic solution is to use CSRF tokens. Each form includes a unique, unpredictable token. The server validates this token before processing state-changing requests (POST, PUT, DELETE).

app/csrf.py
import secrets
import hashlib
from datetime import datetime, timedelta

def generate_csrf_token() -> str:
    """Generate a secure, random CSRF token"""
    return secrets.token_urlsafe(32)

def validate_csrf_token(token: str, session_token: str) -> bool:
    """Validate that token matches session token"""
    return token == session_token

3Implementing CSRF in FastAPI

FastAPI doesn't include CSRF protection out-of-the-box (since it's primarily for JSON APIs). However, you can add it using middleware or dependencies.

app/main.py
from fastapi import FastAPI, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
import secrets
from datetime import datetime, timedelta

app = FastAPI()

# Store tokens in memory (use Redis in production)
csrf_tokens = {}

def generate_csrf_token(session_id: str) -> str:
    """Generate and store CSRF token"""
    token = secrets.token_urlsafe(32)
    csrf_tokens[token] = {
        "session_id": session_id,
        "created_at": datetime.now(),
        "expires_at": datetime.now() + timedelta(hours=1)
    }
    return token

def validate_csrf_token(token: str, session_id: str) -> bool:
    """Validate CSRF token"""
    if token not in csrf_tokens:
        return False

    stored = csrf_tokens[token]
    if stored["session_id"] != session_id:
        return False
    if datetime.now() > stored["expires_at"]:
        return False

    # Token is valid - delete it (one-time use)
    del csrf_tokens[token]
    return True

@app.get("/form")
async def get_form(request: Request):
    """Return HTML form with CSRF token"""
    session_id = "user123"
    token = generate_csrf_token(session_id)

    return HTMLResponse(f'''
    <html>
        <form method="POST" action="/transfer">
            <input type="hidden" name="csrf_token" value="{token}">
            <input type="number" name="amount" placeholder="Amount">
            <button type="submit">Transfer</button>
        </form>
    </html>
    ''')

@app.post("/transfer")
async def transfer(request: Request):
    """Protected endpoint - requires valid CSRF token"""
    form_data = await request.form()
    csrf_token = form_data.get("csrf_token")
    session_id = "user123"

    if not csrf_token or not validate_csrf_token(csrf_token, session_id):
        raise HTTPException(status_code=403, detail="Invalid CSRF token")

    amount = form_data.get("amount")
    return {"message": f"Transferred {amount}", "status": "success"}

4Frontend Integration

Your frontend must include the CSRF token in every state-changing request. For HTML forms, this is automatic. For JavaScript fetch/axios calls, you need to extract and include it manually.

static/form.html
<!DOCTYPE html>
<html>
<head>
    <title>CSRF Protected Form</title>
</head>
<body>
    <h1>Transfer Money</h1>

    <!-- CSRF token embedded in form -->
    <form id="transferForm" method="POST" action="/api/transfer">
        <input type="hidden" id="csrf_token" name="csrf_token">
        <label>
            Amount: <input type="number" name="amount" required>
        </label>
        <label>
            Recipient: <input type="email" name="recipient" required>
        </label>
        <button type="submit">Send Money</button>
    </form>

    <script>
        // Fetch CSRF token from endpoint
        fetch('/csrf-token')
            .then(r => r.json())
            .then(data => {
                document.getElementById('csrf_token').value = data.token;
            });

        // Handle form submission
        document.getElementById('transferForm').addEventListener('submit', async (e) => {
            e.preventDefault();
            const formData = new FormData(e.target);

            const response = await fetch(e.target.action, {
                method: 'POST',
                body: formData
            });

            const result = await response.json();
            alert(result.message);
        });
    </script>
</body>
</html>

5Modern Protection: SameSite Cookies

Modern browsers support SameSite cookie attribute, which prevents browsers from sending cookies on cross-site requests. This is simpler and more effective than CSRF tokens.

app/main.py
from fastapi import FastAPI
from fastapi.responses import Response

app = FastAPI()

@app.get("/login")
async def login():
    """Login endpoint that sets SameSite cookie"""
    response = Response("Logged in", status_code=200)

    response.set_cookie(
        key="session_id",
        value="abc123def456",
        httponly=True,        # Can't access via JavaScript
        secure=True,          # HTTPS only
        samesite="strict"     # Strict: no cookies on cross-site requests
    )

    return response

# OR use Starlette middleware
from starlette.middleware import Middleware
from starlette.middleware.sessions import SessionMiddleware

middleware = [
    Middleware(SessionMiddleware, secret_key="your-secret", max_age=3600)
]

app = FastAPI(middleware=middleware)

SameSite Values:

  • Strict - Cookie only sent for same-site requests (most secure)
  • Lax - Cookie sent on top-level navigation only (recommended)
  • None - Cookie sent on all requests (requires Secure flag, use only if needed)

6Using Starlette-CSRF Library

For production apps, use the starlette-csrf library which handles CSRF protection automatically.

bash
pip install starlette-csrf
app/main.py
from fastapi import FastAPI
from starlette_csrf import CSRFMiddleware
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles

app = FastAPI()

# Add CSRF middleware
app.add_middleware(
    CSRFMiddleware,
    secret="your-secret-key",
    cookie_secure=True,  # HTTPS only
    cookie_httponly=True  # JavaScript cannot access
)

@app.get("/form")
async def get_form():
    """Return form with CSRF protection"""
    return HTMLResponse('''
    <form method="POST" action="/submit">
        <!-- CSRF token injected by middleware -->
        {{ csrf_input }}
        <input type="text" name="username">
        <button type="submit">Submit</button>
    </form>
    ''')

@app.post("/submit")
async def submit_form():
    # Middleware validates CSRF token automatically
    return {"message": "Form submitted successfully"}

7Protecting JSON APIs

For JSON APIs, CSRF tokens are less useful (attackers can't easily make JSON requests from other domains). Instead, rely on SameSite cookies and CORS headers.

app/main.py
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

# CORS middleware prevents cross-origin requests
app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://your-frontend.com"],  # Specific origins only
    allow_credentials=True,
    allow_methods=["GET", "POST", "PUT", "DELETE"],
    allow_headers=["Content-Type"],
)

@app.post("/api/transfer")
async def transfer(request_data: dict):
    """JSON API endpoint protected by CORS + SameSite"""
    # CORS middleware prevents requests from other domains
    # SameSite cookie ensures authentication cookie not sent cross-site
    return {"status": "success"}

Important: JSON APIs are naturally more protected than HTML forms because browsers cannot make cross-domain JSON requests. However, attackers can still trick users with img tags, WebSockets, or using preflight requests.

8Testing CSRF Protection

Always test that your CSRF protection works correctly. Verify that requests without valid tokens are rejected.

tests/test_csrf.py
import pytest
from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)

def test_form_requires_csrf_token():
    """Test that form submission requires CSRF token"""
    response = client.post("/transfer", data={"amount": "100"})
    assert response.status_code == 403
    assert "CSRF" in response.text

def test_form_with_valid_csrf_token():
    """Test that valid CSRF token allows submission"""
    # Get form with token
    form_response = client.get("/form")
    assert form_response.status_code == 200

    # Extract token (simplified - use HTML parser in real code)
    # token = extract_token_from_html(form_response.text)

    # Submit with token
    response = client.post(
        "/transfer",
        data={"csrf_token": "valid_token", "amount": "100"}
    )
    assert response.status_code == 200

def test_invalid_csrf_token_rejected():
    """Test that invalid tokens are rejected"""
    response = client.post(
        "/transfer",
        data={"csrf_token": "invalid_token", "amount": "100"}
    )
    assert response.status_code == 403

def test_token_expires():
    """Test that expired tokens are rejected"""
    from datetime import datetime, timedelta
    from app.csrf import csrf_tokens

    # Create an expired token
    old_token = "expired_token"
    csrf_tokens[old_token] = {
        "session_id": "user123",
        "expires_at": datetime.now() - timedelta(hours=1)
    }

    response = client.post(
        "/transfer",
        data={"csrf_token": old_token, "amount": "100"}
    )
    assert response.status_code == 403

9Common CSRF Mistakes

MistakeProblemSolution
Trusting GET requestsGET requests can be triggered from other sites (img src)Never modify state on GET, use POST/PUT/DELETE
Skipping token validationAttackers bypass CSRF protectionAlways validate on state-changing requests
Reusing tokensAttacker observes one token, uses it repeatedlyUse one-time tokens, regenerate after use
Storing token in URLURL visible in browser history, logs, referrer headersStore in hidden form field or cookie
Weak token generationPredictable tokens attackers can guessUse cryptographically secure random (secrets module)

10Production CSRF Checklist

  • ✅ All state-changing endpoints require CSRF token or SameSite cookies
  • ✅ Tokens are cryptographically random (use secrets module)
  • ✅ Tokens have expiration time (1-24 hours)
  • ✅ Tokens are single-use (deleted after validation)
  • ✅ HTTPS enabled for all endpoints
  • ✅ Cookies set with httponly and secure flags
  • ✅ SameSite cookie attribute set to Strict or Lax
  • ✅ CORS configured to allow only trusted origins
  • ✅ All forms include CSRF token field
  • ✅ CSRF protection tested in unit and integration tests

11Advanced CSRF Patterns

For complex applications, consider double-submit cookies or custom headers.

app/main.py
# Double-submit cookie pattern
# Safer for microservices where token storage is difficult

def generate_csrf_token() -> str:
    return secrets.token_urlsafe(32)

@app.post("/api/transfer")
async def transfer_api(request: Request, authorization: str = Header(None)):
    """API endpoint using double-submit cookie pattern"""

    # Token in both cookie and header
    csrf_from_cookie = request.cookies.get("csrf_token")
    csrf_from_header = request.headers.get("X-CSRF-Token")

    if not csrf_from_cookie or csrf_from_header != csrf_from_cookie:
        raise HTTPException(status_code=403, detail="CSRF validation failed")

    # Process request...
    return {"status": "success"}

12Combining Strategies for Defense in Depth

Don't rely on a single protection method. Combine multiple strategies:

app/main.py
from fastapi import FastAPI
from starlette.middleware.cors import CORSMiddleware
import secrets

app = FastAPI()

# 1. CORS - restrict cross-origin requests
app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://your-frontend.com"],
    allow_credentials=True
)

# 2. SameSite cookies
@app.get("/login")
async def login():
    response = Response("OK")
    response.set_cookie("session", "token", samesite="strict", secure=True)
    return response

# 3. CSRF tokens for forms
@app.post("/api/transfer")
async def transfer(request: Request):
    form = await request.form()
    csrf_token = form.get("csrf_token")

    # Validate CSRF
    if not csrf_token or csrf_token not in valid_tokens:
        raise HTTPException(status_code=403)

    # Validate SameSite cookie was sent
    if "session" not in request.cookies:
        raise HTTPException(status_code=403)

    # Process transfer
    return {"status": "success"}

Defense in Depth: Using CSRF tokens + SameSite cookies + CORS + HTTPS creates multiple layers of protection.

13Resources & Security Benchmarks

CSRF protection is critical for any web application. Always stay updated with OWASP guidelines and security best practices.

  • OWASP CSRF Prevention: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
  • MDN SameSite Cookies: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite
  • FastAPI Security Docs: https://fastapi.tiangolo.com/advanced/security/
  • Starlette-CSRF Library: https://github.com/frankie567/starlette-csrf

14Summary & Best Practices

CSRF attacks are serious but easily prevented with proper implementation. Use CSRF tokens for HTML forms, rely on SameSite cookies for modern browsers, and combine with CORS for defense in depth.

ScenarioRecommendation
HTML FormsUse CSRF tokens + SameSite=Strict
JSON APIRely on SameSite + CORS
MicroservicesDouble-submit cookie pattern
Public APIUse API keys instead of cookies
Mobile AppUse CSRF token in Authorization header

15What's Next

Master CSRF protection and secure your FastAPI applications. Ready to level up?

🚀 Congratulations! You now understand CSRF attacks and how to protect your FastAPI forms. Implement these strategies to keep your users safe from unauthorized requests!

About the Author

TG

Thirdy Gayares

Passionate developer creating custom solutions for everyone. I specialize in building user-friendly tools that solve real-world problems while maintaining the highest standards of security and privacy.