🎓 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
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. User logs into bank.com
- 2. User visits malicious.com in another tab
- 3. malicious.com sends request to bank.com/transfer
- 4. Browser includes bank.com cookies automatically
- 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).
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.
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.
<!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.
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.
pip install starlette-csrffrom 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.
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.
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
| Mistake | Problem | Solution |
|---|---|---|
| Trusting GET requests | GET requests can be triggered from other sites (img src) | Never modify state on GET, use POST/PUT/DELETE |
| Skipping token validation | Attackers bypass CSRF protection | Always validate on state-changing requests |
| Reusing tokens | Attacker observes one token, uses it repeatedly | Use one-time tokens, regenerate after use |
| Storing token in URL | URL visible in browser history, logs, referrer headers | Store in hidden form field or cookie |
| Weak token generation | Predictable tokens attackers can guess | Use cryptographically secure random (secrets module) |
10Production CSRF Checklist
- ✅ All state-changing endpoints require CSRF token or SameSite cookies
- ✅ Tokens are cryptographically random (use
secretsmodule) - ✅ Tokens have expiration time (1-24 hours)
- ✅ Tokens are single-use (deleted after validation)
- ✅ HTTPS enabled for all endpoints
- ✅ Cookies set with
httponlyandsecureflags - ✅ 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.
# 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:
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.
| Scenario | Recommendation |
|---|---|
| HTML Forms | Use CSRF tokens + SameSite=Strict |
| JSON API | Rely on SameSite + CORS |
| Microservices | Double-submit cookie pattern |
| Public API | Use API keys instead of cookies |
| Mobile App | Use 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!