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.
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.
-
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.
-
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.
- These clients are not vulnerable to CSRF because they don't automatically send cookies like browsers do. They authenticate explicitly, typically using an
We will use the fastapi-csrf-protect
library for the browser-based implementation.
First, install the necessary packages.
pip install "fastapi[all]" fastapi-csrf-protect jinja2 python-dotenv
fastapi[all]
gives youuvicorn
for running the server andpython-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.
Organize your project files like this:
.
├── .env
├── main.py
└── templates/
└── form.html
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
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."}
)
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>
Here is how each type of client would interact with your secure FastAPI application.
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 thecsrf_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.
Your frontend JavaScript needs to create an item by calling POST /api/items
.
- First, get the CSRF token by making a call to your dedicated endpoint.
- 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 noAuthorization
header, so it proceeds to CSRF validation. It compares the token from thefastapi-csrf-token
cookie with the token from theX-CSRF-Token
header.
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 theAuthorization: Bearer ...
header. It validates the API keymy-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.