Skip to content

Instantly share code, notes, and snippets.

@rbricheno
Created August 8, 2025 10:51
Show Gist options
  • Save rbricheno/f079fb2c301f8e24d679364f58ddd092 to your computer and use it in GitHub Desktop.
Save rbricheno/f079fb2c301f8e24d679364f58ddd092 to your computer and use it in GitHub Desktop.
FastAPI CSRF protection proposal

A Complete Guide to CSRF Protection in FastAPI

FastAPI is an unopinionated framework, meaning it doesn't include built-in CSRF (Cross-Site Request Forgery) protection. This guide outlines a robust, stateless, and flexible strategy to secure your application for all types of clients.

The Strategy: Conditional Protection

Our approach will be to use the Double Submit Cookie Pattern for browser-based clients and Token-Based Authentication for non-browser clients, using a single "smart" dependency to differentiate between them.

  1. For Browser Users (Forms & AJAX):

    • The server generates a CSRF token and sets it in a cookie.
    • For every state-changing request (POST, PUT, etc.), the client must send this token back, either in a hidden form field or a custom HTTP header (X-CSRF-Token).
    • The server validates that the token from the cookie matches the token from the request. This works because a malicious site cannot read your site's cookies to forge the request properly.
  2. For Non-Browser API Users (Scripts, Apps):

    • These clients are not vulnerable to CSRF because they don't automatically send cookies like browsers do. They authenticate explicitly, typically using an Authorization header with a Bearer Token or API key.
    • For these clients, we will bypass CSRF checks and validate their API key instead.

Step-by-Step Implementation

We will use the fastapi-csrf-protect library for the browser-based implementation.

Step 1: Installation

First, install the necessary packages.

pip install "fastapi[all]" fastapi-csrf-protect jinja2 python-dotenv
  • fastapi[all] gives you uvicorn for running the server and python-multipart for form handling.
  • fastapi-csrf-protect is our core CSRF library.
  • jinja2 is for rendering HTML templates.
  • python-dotenv is for securely managing secret keys.

Step 2: Project Structure

Organize your project files like this:

.
├── .env
├── main.py
└── templates/
    └── form.html

Step 3: Configuration (.env)

Create a .env file to store your secret key. Never hardcode secrets in your code.

.env

# Generate a strong random key for production, e.g., using: openssl rand -hex 32
CSRF_SECRET_KEY=your-super-secret-and-random-key

Step 4: Final Code (main.py)

Here is the complete FastAPI application code that implements the conditional logic.

main.py

import os
import secrets
from dotenv import load_dotenv
from typing import Optional

from fastapi import FastAPI, Request, Depends, Header, HTTPException, status
from fastapi.responses import JSONResponse, HTMLResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from fastapi_csrf_protect import CsrfProtect
from fastapi_csrf_protect.exceptions import CsrfProtectError

# --- Initial Setup ---
load_dotenv()
app = FastAPI()
templates = Jinja2Templates(directory="templates")


# --- CSRF Protection Configuration ---
class CsrfSettings(BaseModel):
    secret_key: str = os.environ.get("CSRF_SECRET_KEY")

@CsrfProtect.load_config
def get_csrf_config():
    return CsrfSettings()

@app.exception_handler(CsrfProtectError)
def csrf_protect_exception_handler(request: Request, exc: CsrfProtectError):
    """Custom exception handler for CSRF errors to return a JSON response."""
    return JSONResponse(status_code=exc.status_code, content={"detail": exc.message})


# --- API Key Authentication (for non-browser clients) ---
# In a real app, securely store and look up keys from a database.
VALID_API_KEYS = {"my-secret-api-key-12345": "script_client"}

def validate_api_key(api_key: str) -> bool:
    """A placeholder for a secure API key validation function."""
    # Use `secrets.compare_digest` to prevent timing attacks in production
    return api_key in VALID_API_KEYS


# --- The "Smart" Dependency for Protected Routes ---
async def protected_route(
    request: Request,
    authorization: Optional[str] = Header(None),
    csrf_protect: CsrfProtect = Depends()
):
    """
    A dependency that protects a route with either an API key or CSRF protection.
    - If an Authorization header is present, it's treated as an API user.
    - Otherwise, it's treated as a browser user and CSRF is enforced.
    """
    if authorization:
        scheme, _, credentials = authorization.partition(" ")
        if scheme.lower() != "bearer" or not validate_api_key(credentials):
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail="Invalid or missing API key",
            )
        # API key is valid, so we can proceed.
        return

    # No Authorization header, so this must be a browser user.
    # Enforce CSRF protection.
    await csrf_protect.validate_csrf(request)


# --- API Routes ---

class Item(BaseModel):
    name: str

# This endpoint is shared by browser and API clients
@app.post("/api/items", summary="Create item (Browser/AJAX and API clients)", dependencies=[Depends(protected_route)])
def create_item(item: Item):
    """
    Protected by our 'smart' dependency. It will accept requests with either:
    1. A valid 'Authorization: Bearer <key>' header (for API clients).
    2. A valid CSRF token cookie and header/form-data (for browser clients).
    """
    return JSONResponse(
        status_code=201,
        content={"message": f"Item '{item.name}' created successfully."}
    )

# This endpoint is specifically for browser-based AJAX clients to get a token
@app.get("/api/csrf-token", summary="Get CSRF token for AJAX")
def get_csrf_token(csrf_protect: CsrfProtect = Depends()):
    """
    Sets the CSRF-token in a cookie and returns the token in the response body
    for the frontend JavaScript to use in a request header.
    """
    response = JSONResponse(status_code=200, content={"csrf_token": csrf_protect.generate_csrf()})
    # The library automatically sets the cookie on the response
    return response


# --- HTML Form Routes (Browser only) ---

@app.get("/form", response_class=HTMLResponse, summary="Display an HTML form")
def get_form(request: Request, csrf_protect: CsrfProtect = Depends()):
    """
    Renders a form and injects the CSRF token into a hidden field.
    """
    csrf_token = csrf_protect.generate_csrf()
    context = {"request": request, "csrf_token": csrf_token}
    return templates.TemplateResponse("form.html", context)

@app.post("/submit-form", summary="Process HTML form submission")
async def submit_form(request: Request, csrf_protect: CsrfProtect = Depends()):
    """
    This endpoint is for browser form submissions only, so it uses the standard
    CSRF dependency to validate the token from the hidden form field.
    """
    await csrf_protect.validate_csrf(request)
    form_data = await request.form()
    username = form_data.get("username")
    return JSONResponse(
        status_code=200,
        content={"message": f"Hello, {username}! Your form was submitted securely."}
    )

Step 5: The HTML Template

Create the templates/form.html file.

templates/form.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>CSRF Form Example</title>
</head>
<body>
    <h1>Secure Form</h1>
    <form action="/submit-form" method="post">
        <!-- This hidden input is crucial for form-based CSRF protection -->
        <input type="hidden" name="csrf_token" value="{{ csrf_token }}">

        <label for="username">Username:</label>
        <input type="text" id="username" name="username" required>
        <br><br>
        <button type="submit">Submit</button>
    </form>
</body>
</html>

Client-Side Usage: The Three Scenarios

Here is how each type of client would interact with your secure FastAPI application.

Scenario 1: HTML Form Submission

A user visits /form. Your server renders the HTML with the hidden csrf_token input. When the user clicks "Submit", the browser sends a POST request to /submit-form.

  • What happens: The browser automatically includes both the fastapi-csrf-token cookie and the csrf_token from the hidden form field.
  • Validation: The csrf_protect dependency on the /submit-form route compares the token from the cookie to the token from the form data. If they match, the request is allowed.

Scenario 2: AJAX Request from a Web Frontend

Your frontend JavaScript needs to create an item by calling POST /api/items.

  1. First, get the CSRF token by making a call to your dedicated endpoint.
  2. Then, make the protected request, including the token in the X-CSRF-Token header.

Here is an example using fetch in JavaScript:

// Function to get the CSRF token
async function getCsrfToken() {
    const response = await fetch('/api/csrf-token');
    const data = await response.json();
    return data.csrf_token;
}

// Function to create an item using the token
async function createItemWithAjax(itemName) {
    const csrfToken = await getCsrfToken();
    if (!csrfToken) {
        console.error("Could not get a CSRF token. Aborting.");
        return;
    }

    try {
        const response = await fetch('/api/items', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                // The library's default header name is 'X-CSRF-Token'
                'X-CSRF-Token': csrfToken
            },
            body: JSON.stringify({ name: itemName })
        });

        const result = await response.json();
        if (response.ok) {
            console.log('Success:', result.message);
        } else {
            console.error('Error:', result.detail);
        }
    } catch (error) {
        console.error("Network or other error:", error);
    }
}

// Example usage:
// createItemWithAjax('Item from AJAX');
  • Validation: The protected_route dependency on /api/items sees no Authorization header, so it proceeds to CSRF validation. It compares the token from the fastapi-csrf-token cookie with the token from the X-CSRF-Token header.

Scenario 3: Request from an API User (Script/Service)

An external script or service wants to create an item. It will use its API key and ignore the CSRF flow completely.

Here is an example using curl:

curl -X POST "http://127.0.0.1:8000/api/items" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer my-secret-api-key-12345" \
-d '{
    "name": "Item from API Script"
}'
  • Validation: The protected_route dependency on /api/items sees the Authorization: Bearer ... header. It validates the API key my-secret-api-key-12345. Since the key is valid, it bypasses the CSRF check and allows the request to proceed.

This comprehensive setup provides robust, layered security for all your application's clients on the same endpoints.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment