Understanding CSRF Protection in sillo
Section titled “Understanding CSRF Protection in sillo”What is CSRF?
Section titled “What is CSRF?”Cross-Site Request Forgery (CSRF) is a security vulnerability that tricks users into performing unwanted actions on web applications where they’re authenticated. Attackers can force users to execute state-changing requests (like changing passwords, making purchases, or transferring funds) without their knowledge.
⚠️ Why CSRF Protection Matters
Section titled “⚠️ Why CSRF Protection Matters”Imagine this scenario:
- You’re logged into your bank’s website
- You visit a malicious website in another tab
- That site contains hidden forms or scripts that submit requests to your bank
- Because you’re already authenticated, these requests appear legitimate
Without CSRF protection, these malicious requests could perform harmful actions on your behalf.
How CSRF Protection Works
Section titled “How CSRF Protection Works”sillo implements the “Synchronizer Token Pattern”:
- Token Generation: A unique, secure token is generated when a user visits your site
- Token Storage: Stored in an HTTP-only cookie and server session
- Token Validation: Required for state-changing requests (POST, PUT, DELETE, etc.)
- Request Verification: Server verifies the token matches the session
Basic Setup
Section titled “Basic Setup”from sillo import silloAppfrom sillo.security.csrf import CSRFConfig, CSRFMiddleware
csrf_config = CSRFConfig( enabled=True, secret_key="your-secret-key-here", # Required: used to sign CSRF tokens required_urls=["*"], safe_methods=["GET", "HEAD", "OPTIONS"], cookie_name="csrftoken", header_name="X-CSRFToken")
app = silloApp()app.use(CSRFMiddleware(config=csrf_config))Configuration Options
Section titled “Configuration Options”sillo provides flexible configuration to customize CSRF protection for your application’s needs. Here’s a detailed breakdown of each option:
Core Settings
Section titled “Core Settings”-
enabled(boolean, default:False)- Enables or disables CSRF protection globally
- Recommended:
Truein production environments - Example:
CSRFConfig(enabled=True)
-
secret_key(string, required)- Cryptographic key used to sign CSRF tokens
- Security Note: Keep this secret and consistent across application restarts
- Example:
CSRFConfig(secret_key="your-secure-key-123")
URL Configuration
Section titled “URL Configuration”-
required_urls(list of strings, default:["*"])- URL patterns that require CSRF protection
- Supports wildcard
*for matching multiple URLs - Example:
["/api/*", "/admin/*"]
-
exempt_urls(list of strings, default:[])- URL patterns excluded from CSRF protection
- Takes precedence over
required_urls - Example:
["/api/public/*", "/webhooks/stripe"]
HTTP Methods
Section titled “HTTP Methods”safe_methods(list of strings, default:["GET", "HEAD", "OPTIONS"])- HTTP methods that don’t require CSRF tokens
- These should be idempotent and have no side effects
- Example:
["GET", "HEAD", "OPTIONS", "TRACE"]
Cookie Settings
Section titled “Cookie Settings”-
cookie_name(string, default:"csrftoken")- Name of the cookie that stores the CSRF token
- Change this if you need to avoid naming conflicts
- Example:
CSRFConfig(cookie_name="myapp_csrf_token")
-
cookie_secure(boolean, default:False)- When
True, the cookie is only sent over HTTPS - Security Best Practice: Set to
Truein production - Example:
CSRFConfig(cookie_secure=True)
- When
-
cookie_httponly(boolean, default:True)- Prevents JavaScript from accessing the cookie
- Security Best Practice: Keep this as
True - Example:
CSRFConfig(cookie_httponly=True)
-
cookie_samesite(string, default:"lax")- Controls when cookies are sent with cross-site requests
- Options:
"lax"(recommended),"strict", or"none" - Note:
"none"requiressecure=True - Example:
CSRFConfig(cookie_samesite="lax")
Headers and Forms
Section titled “Headers and Forms”-
header_name(string, default:"X-CSRFToken")- HTTP header name for sending CSRF tokens in AJAX requests
- Example:
CSRFConfig(header_name="X-CSRF-TOKEN")
-
form_field(string, default:"csrf_token")- Form field name for CSRF tokens in HTML forms
- Must match your form field names
- Example:
CSRFConfig(form_field="_csrf_token")
-
cookie_path(string, default:"/")- Path for which the cookie is valid
- Example:
CSRFConfig(cookie_path="/api")
Using CSRF with Templates
Section titled “Using CSRF with Templates”When working with sillo templates, you can easily include CSRF tokens in your forms. The CSRF token is automatically added to the request state and can be accessed in your templates.
1. Basic Form with CSRF Token
Section titled “1. Basic Form with CSRF Token”First, ensure you have a template file (e.g., templates/login.html):
<!DOCTYPE html><html> <head> <title>Login</title> </head> <body> <h1>Login</h1> <form method="post" action="/login"> <input type="hidden" name="csrftoken" value="{{ csrf_token }}" />
<div class="form-group"> <label for="username">Username:</label> <input type="text" id="username" name="username" required /> </div>
<div class="form-group"> <label for="password">Password:</label> <input type="password" id="password" name="password" required /> </div>
<button type="submit">Login</button> </form> </body></html>2. Route Handler with Template Rendering
Section titled “2. Route Handler with Template Rendering”In your route handler, use the render function to render the template with the CSRF token:
from sillo.templating import render
@app.get("/login")async def login_get(request, response): # The CSRF token is automatically available in the template context return await render("login.html", request=request)
@app.post("/login")async def login_post(request, response): form = await request.form # CSRF validation happens automatically via the middleware
# Your login logic here if form.get("username") == "admin" and form.get("password") == "password": return "Login successful!" return "Invalid credentials"3. Using Template Context Middleware (Recommended)
Section titled “3. Using Template Context Middleware (Recommended)”For better organization, use the TemplateContextMiddleware to automatically inject the CSRF token into all your templates:
from sillo.templating.middleware import TemplateContextMiddleware
# Add this before your route definitionsapp.use(TemplateContextMiddleware())
# Now all templates will have access to the CSRF token as `{{ csrf_token }}`4. AJAX Requests with CSRF
Section titled “4. AJAX Requests with CSRF”For AJAX requests, include the CSRF token in your JavaScript:
// Include this in your base template<script> // Get CSRF token from meta tag const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
// Example AJAX request async function submitForm() { const response = await fetch('/api/endpoint', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken }, body: JSON.stringify({ data: 'example' }) }); return await response.json(); }</script>5. Customizing the CSRF Field Name
Section titled “5. Customizing the CSRF Field Name”If you need to use a different field name for the CSRF token in your forms, you can customize it in your configuration:
csrf_config = CSRFConfig( # ... other config form_field="custom_csrf_field", # Default is "csrftoken")app.use(CSRFMiddleware(config=csrf_config))Then update your form to use the custom field name:
<form method="post"> <input type="hidden" name="custom_csrf_field" value="{{ csrf_token }}" /> <!-- form fields --></form>Best Practices
Section titled “Best Practices”- Always use HTTPS in production to protect the CSRF token in transit.
- Don’t expose the CSRF token in logs or error messages.
- Use the TemplateContextMiddleware to automatically include the CSRF token in all templates.
- Protect all state-changing endpoints (POST, PUT, DELETE, PATCH) with CSRF tokens.
- Use the same-site cookie attribute to provide additional protection against CSRF attacks.
Client-Side Implementation
Section titled “Client-Side Implementation”1. HTML Forms
Section titled “1. HTML Forms”For traditional form submissions, include the CSRF token in a hidden field. The token should be included in every form that performs state-changing operations (POST, PUT, DELETE, etc.).
<!-- Example: User Profile Update Form --><form method="post" action="/profile/update"> <div class="form-group"> <label for="username">Username</label> <input type="text" id="username" name="username" value="{{ current_user.username }}" required /> </div>
<div class="form-group"> <label for="email">Email</label> <input type="email" id="email" name="email" value="{{ current_user.email }}" required /> </div>
<button type="submit" class="btn btn-primary">Update Profile</button></form>2. JavaScript (AJAX) Requests
Section titled “2. JavaScript (AJAX) Requests”For AJAX requests, you’ll need to:
- Extract the CSRF token from cookies
- Include it in the request headers
// Example AJAX requestasync function submitForm() { const csrfToken = document.cookie.match(/csrftoken=([^\s]*)/)[1]; const response = await fetch("/api/endpoint", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRFToken": csrfToken, }, body: JSON.stringify({ data: "example" }), }); return await response.json();}How a request is checked
Section titled “How a request is checked”CSRFMiddleware.process_request runs this sequence for every request (only when enabled=True):
- Generate a fresh signed token and stash it on
request.state.csrf_token. - If the method is in
safe_methods(GET,HEAD,OPTIONSby default), allow the request through. - Otherwise, if the path matches
required_urls— or matchesexempt_urlsand carries a sensitive cookie — validation runs:- The token cookie (
cookie_name) must be present. - A submitted token must be present, read from the
X-CSRFTokenheader, or (for form-urlencoded bodies) thecsrftokenform field. - The submitted token must match the cookie token.
- The token cookie (
- Any failure (missing cookie, missing token, mismatch) returns
403and clears the CSRF cookie.
On the way out, process_response sets the csrftoken cookie so the next request can present a matching header. The cookie is HttpOnly by default, so JavaScript cannot read it — the token must travel in the header (or form field), which is what makes the pattern resistant to cross-site forgery.
Testing
Section titled “Testing”Drive CSRF through TestClient: fetch a token-bearing response, then replay the cookie + header on a POST.
from sillo import silloAppfrom sillo.security import CSRFMiddleware, CSRFConfigfrom sillo.testclient import TestClient
def test_valid_token_passes(): app = silloApp() app.use(CSRFMiddleware(CSRFConfig(enabled=True, secret_key="secret")))
@app.post("/transfer") async def transfer(request, response): return {"ok": True}
client = TestClient(app) token_resp = client.get("/") # any GET primes the cookie cookie = token_resp.cookies["csrftoken"]
resp = client.post( "/transfer", headers={"X-CSRFToken": cookie}, cookies={"csrftoken": cookie}, ) assert resp.status_code == 200
def test_missing_token_rejected(): app = silloApp() app.use(CSRFMiddleware(CSRFConfig(enabled=True, secret_key="secret")))
@app.post("/transfer") async def transfer(request, response): return {"ok": True}
resp = TestClient(app).post("/transfer") assert resp.status_code == 403Related topics
Section titled “Related topics”- Security Headers (Shield) — defensive response headers
- CORS — cross-origin access control
- Authentication — who the caller is
Why CSRF exists, precisely
Section titled “Why CSRF exists, precisely”A browser attaches cookies to a request based on its destination, not its
origin. A form on evil.example that posts to bank.example/transfer
carries the user’s bank.example session cookie, and from the server’s
perspective the request is indistinguishable from a legitimate one.
That is the entire attack. The defence is to require something the attacker’s page cannot read or guess.
Two properties make CSRF specifically a cookie problem. A form POST needs no JavaScript, so no CORS preflight blocks it. And cookies are sent automatically, so the attacker never needs to steal one.
An API authenticating with an Authorization header is not vulnerable in
the same way — the attacker’s page cannot set that header cross-origin,
and attempting to triggers a preflight that CORS refuses. If your
session lives in a cookie, you need CSRF protection. If it lives in a
header, you largely do not.
SameSite is a strong defence, not a complete one
Section titled “SameSite is a strong defence, not a complete one”SameSite=Lax stops cookies riding along on cross-site POSTs, which
removes the classic attack. It is a genuine improvement and worth setting
on every session cookie.
It is not a replacement for tokens. Lax still sends cookies on
top-level GET navigations, so any state-changing GET remains
exposed — which is one more reason GET must never change state.
Browser support is universal now but the enforcement details vary, and a
subdomain you do not control is same-site for cookie purposes.
Defence in depth: set SameSite=Lax (or Strict where the UX allows),
and validate a token on every state-changing request.
Where the token has to appear
Section titled “Where the token has to appear”The token must be somewhere the attacker cannot read: a form field, or a request header. It must not be in a cookie alone, because the attacker’s page causes that cookie to be sent without ever seeing it.
The double-submit pattern — token in a cookie and in a header, compared server-side — works because reading the cookie to copy it into the header requires same-origin JavaScript. It is weaker than a server-side session token when subdomains are involved, since a compromised subdomain can write cookies for the parent domain.
Three things that must be exempt or handled specially: webhook endpoints, which have no browser and no token; login itself, where no session exists yet; and anything authenticating by header rather than cookie.
Failure modes
Section titled “Failure modes”Two things go wrong with CSRF protection, in opposite directions.
Too strict breaks legitimate flows: a token that expires with a short session means a user who leaves a form open gets an error on submit. Give the token the session’s lifetime, and return a clear, specific message — “your session expired, reload and try again” — rather than a bare 403, which users read as “you are not allowed”.
Too permissive is the exemption list. Every exempt endpoint is an endpoint without protection, and exemptions accumulate: one for a webhook, one for a legacy client, one added during an incident. Review the list periodically and require a reason for each entry.