⚡ 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.

Discussion & Fixes

ThiefMaster 2025-12-15
> Handling Missing Headers: Current proposal allows requests with valid Origin header when Sec-Fetch-Site is missing. Should this be configurable or should we reject outright? Requiring `Origin` seems like a massive breaking change for APIs (where CSRF is generally not an issue anyway since you need to provide a Bearer token for authentication, or for unauthenticated APIs that do not require CSRF protections). So default-on would likely be a breaking change.
sharoonthomas 2025-12-15
> > Handling Missing Headers: Current proposal allows requests with valid Origin header when Sec-Fetch-Site is missing. Should this be configurable or should we reject outright? > > Requiring `Origin` seems like a massive breaking change for APIs (where CSRF is generally not an issue anyway since you need to provide a Bearer token for authentication, or for unauthenticated APIs that do not require CSRF protections). > > So default-on would likely be a breaking change. The algorithm I was hoping to implement was Filippo's recommended appraoch: 1. Allow safe methods (GET, HEAD, OPTIONS) 2. If Origin in trusted list → allow 3. If Sec-Fetch-Site present: - same-origin or none → allow - same-site or cross-site → REJECT 4. If neither header present → allow (not a browser) 5. If Origin host == Host → allow, else reject Based on this. **What gets rejected:** - Browser requests where Sec-Fetch-Site is cross-site or same-site - Browser requests where Origin is present but doesn't match Host (old browsers) **What gets allowed:** - Sec-Fetch-Site: same-origin (browser, same origin) - Sec-Fetch-Site: none (browser, user-initiated like bookmarks) - No Sec-Fetch-Site + no Origin (not a browser — API clients) - No Sec-Fetch-Site + Origin matches Host (old browser, same origin) - Any request to a trusted origin in CSRF_TRUSTED_ORIGINS So default-on should not break APIs at all. The protection only kicks in when browser-specific headers are present and indicate a cross-origin request. Does this address your concern, or am I missing an edge case?
ThiefMaster 2025-12-15
Yeah, I think "neither header present → allow (not a browser)" addresses my concern.
davidism 2025-12-15
This was already on my radar. I've experimented a bit with a middleware in Werkzeug or an implementation in Flask (or both). The difficulty comes from figuring out a nice way to manage allowed origins and exempt paths. Go's implementation reuses its routing implementation, but creating a separate map of exempt paths is a bit more expensive (and difficult to sync configurations) in Werkzeug/Flask. I'm also not sure if we could use `TRUSTED_HOSTS` as the allowed origins as well, but I'm pretty sure we can't as "host" is different from "origin".
sharoonthomas 2025-12-16
@davidism Here's how I was thinking of implementing this. **Option 1: Flask + Werkzeug implementation** 1. Accept csrf as a parameter to `@app.route | @blueprint.route` but will still be in `**options` kwargs 2. `werkzeug.routing.Rule` [class](https://github.com/pallets/werkzeug/blob/a9f6b3c7924912e62ea74f39f10fb611d6e7725e/src/werkzeug/routing/rules.py#L459-L473) to have the argument `csrf` and attribute `csrf` 3. In `flask.App.full_dispatch_request`, call `self.check_csrf(ctx.request)` after `request_started.send` and before `self.preprocess_request` https://github.com/pallets/flask/blob/2579ce9f18e67ec3213c6eceb5240310ccd46af8/src/flask/app.py#L1001-L1002 **Option 2: Flask only implementation** 1. Same as above 2. Instead of adding argument to `werkzeug.routing.Rule`, just set `rule_obj.csrf = options.get("csrf")` similar to how `provide_automatic_options` is set. 3. Same as above https://github.com/pallets/flask/blob/2579ce9f18e67ec3213c6eceb5240310ccd46af8/src/flask/sansio/app.py#L647-L648 --- **On exempt paths:** This approach avoids the "separate map of exempt paths" problem entirely. Since `csrf` is stored on the `Rule` object itself, we just check `request.url_rule.csrf` at dispatch time requiring no separate data structure to sync. **On `TRUSTED_HOSTS` vs origins:** You're right, they're different. So I'd keep them separate: - `TRUSTED_HOSTS`: existing, for [Host header validation](https://flask.palletsprojects.com/en/stable/api/#flask.Request.trusted_hosts) - `CSRF_TRUSTED_ORIGINS`: new, full origins for cross-origin allowlist (OAuth callbacks, etc.) **On Werkzeug vs Flask:** I lean toward Option 2 (Flask only) since: - The check logic references Flask's `app.config` for `CSRF_TRUSTED_ORIGINS` - It follows the `provide_automatic_options` precedent - Werkzeug stays transport-layer focused The core check logic (~40 lines) could still live in Werkzeug as a utility function that Flask calls, if you want other frameworks to benefit. But the routing integration feels Flask-specific. Want me to put together a draft PR for Option 2?
davidism 2025-12-17
You're using an LLM tool to write for you. If I wanted to chat about and idea and design with an LLM, I could do so directly at any time, I don't need you as an intermediary. Closing, I'll work on this myself at some point.

Prevent idempotency bugs?

SolvedStack indexes fixes, but OnceOnly prevents duplicate processing and race conditions by design.

Try OnceOnly