⚡ Solution Summary
- Implement CSRF protection using Sec-Fetch-Site header for POST requests.
- Allow safe methods (GET, HEAD, OPTIONS) without CSRF checks.
- Consider making the requirement for Origin header configurable.
- Use a parameter in @app.route to manage CSRF settings.
- Manage allowed origins and exempt paths efficiently.
Flask's documentation states that CSRF protection requires a form validation framework (which Flask doesn't provide), necessitating one-time tokens stored in cookies and transmitted with form data. However, modern browsers now support `Sec-Fetch-Site` headers, making token-based CSRF protection unnecessary. Rails just merged this approach in [rails/rails#56350](https://github.com/rails/rails/pull/56350). Flask should offer a similar modern solution—but in a more Flask-like way: as a simple argument to `@app.route()`.
## Current State
From Flask's security documentation:
> "Why does Flask not do that for you? The ideal place for this to happen is the form validation framework, which does not exist in Flask."
This was written when CSRF tokens were the only viable protection mechanism. That's no longer true. And with header-based protection, no form validation framework is needed—just a simple check before dispatching to the view.
## The Modern Approach
The `Sec-Fetch-Site` header is a [Fetch Metadata Request Header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Site) that modern browsers send automatically. It indicates the relationship between the request origin and the target origin:
- `same-origin`: Request from the same origin (same scheme, host, and port)
- `same-site`: Request from the same site but different origin
- `cross-site`: Request from a completely different site
- `none`: User-initiated navigation (typing URL, bookmark, etc.)
For state-changing requests (POST, PUT, DELETE, PATCH), we can simply reject requests where `Sec-Fetch-Site` is `cross-site`. No tokens needed.
**Browser Support:** All modern browsers (Chrome 76+, Firefox 90+, Safari 16.4+, Edge 79+) - [caniuse.com/mdn-http_headers_sec-fetch-site](https://caniuse.com/mdn-http_headers_sec-fetch-site)
## Why This Matters
Token-based CSRF has significant operational pain:
1. **Caching conflicts**: Cached pages contain stale tokens → false positives
2. **Session expiry edge cases**: Token/session mismatch after timeout
3. **SPA complexity**: Managing token refresh in JavaScript applications
4. **Multi-tab issues**: Tokens invalidated when user opens multiple tabs
Header-based protection eliminates all of these.
## Proposed Flask Implementation
Rather than adding another extension, this should be a first-class citizen in Flask's routing. The implementation is simple enough that it belongs in core—just an argument to `@app.route()`:
```python
from flask import Flask
app = Flask(__name__)
app.config['CSRF_TRUSTED_ORIGINS'] = ['https://accounts.google.com']
# Protected by default for state-changing methods
@app.route('/api/data', methods=['POST'])
def create_data():
return {'status': 'ok'}
# Explicitly disable for webhooks that use signature verification
@app.route('/webhooks/stripe', methods=['POST'], csrf=False)
def stripe_webhook():
return {'received': True}
# GET requests are never protected (no state change)
@app.route('/api/data', methods=['GET'])
def get_data():
return {'data': []}
```
## Configuration
```python
# Default configuration in Flask
CSRF_ENABLED = True # Global kill switch
CSRF_TRUSTED_ORIGINS = [] # Allow cross-origin from these origins
CSRF_PROTECTED_METHODS = {'POST', 'PUT', 'PATCH', 'DELETE'}
```
Note: Unlike my earlier draft, `same-site` requests are **rejected by default**. This is intentional—different subdomains often have different trust levels (e.g., `marketing.example.com` vs `admin.example.com`). If you need same-site requests, add the specific origin to `CSRF_TRUSTED_ORIGINS`.
## Questions for Maintainers
1. **Default On vs Off**: Should CSRF protection be on by default for state-changing methods (proposed), or require explicit `csrf=True`?
2. **Same-site Policy**: The algorithm rejects `same-site` requests by default (per Filippo Valsorda's guidance). Should there be a config option to relax this, or is explicit `CSRF_TRUSTED_ORIGINS` sufficient?
3. **Werkzeug Level**: Should the core check logic live in Werkzeug so other frameworks (Bottle, etc.) can use it?
## References
- [MDN: Sec-Fetch-Site](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Site)
- [web.dev: Fetch Metadata](https://web.dev/articles/fetch-metadata)
- [Rails PR #56350](https://github.com/rails/rails/pull/56350) - Rails implementation (merged Dec 2025)
- [Rack Issue #2367](https://github.com/rack/rack/issues/2367) - Rack-level discussion
- [Go proposal: CrossOriginForgeryHandler](https://github.com/golang/go/issues/73626)
- [Blog from go author](https://words.filippo.io/csrf/)
- [OWASP Fetch Metadata positioning](https://github.com/OWASP/CheatSheetSeries/pull/1875)
## Willingness to Implement
I'm prepared to submit a PR implementing this. The change is small and self-contained.
Prevent idempotency bugs?
SolvedStack indexes fixes, but OnceOnly prevents duplicate processing and race conditions by design.
Try OnceOnly
Discussion & Fixes