· 6 min read ·

Sec-Fetch-Site and the End of CSRF Token Boilerplate

Source: simonwillison

The classic CSRF token exists because browsers have never been able to tell a server, in a trustworthy way, where a request originated. That gap is closing. Simon Willison’s PR #2689 for Datasette replaces the project’s entire token-based CSRF machinery with a check on the Sec-Fetch-Site header, and the reasoning behind that change is worth understanding in depth, not just for Datasette, but for any web application making security decisions in 2026.

The Security Model Behind Token-Based CSRF

CSRF exploits the fact that browsers attach cookies to every request to a domain, regardless of where the request originates. A page on evil.com can cause your browser to POST to bank.com/transfer, and the bank’s server sees a valid session cookie. The classic defense, the synchronizer token pattern, works around this by embedding a secret in the form that the attacker cannot read, thanks to the Same-Origin Policy.

The token approach is defensively sound but operationally expensive. Every form needs a hidden field. Every AJAX call needs to extract the token from the DOM or a cookie and attach it as a header. Every API client, whether curl, Python scripts, or a CI pipeline, either needs to fetch the token first or receive a special exemption. Cached pages cannot include the token without cache-busting. The entire asgi-csrf library existed in the Datasette dependency tree just to manage this dance.

What Sec-Fetch-Site Is

The W3C Fetch Metadata Request Headers specification defines a set of request headers the browser sends, prefixed Sec-Fetch-, that web content cannot set or override. The prefix Sec- signals browser-controlled headers; they appear on the forbidden header names list, meaning any attempt by JavaScript to set them via fetch() or XMLHttpRequest is silently discarded.

Sec-Fetch-Site describes the relationship between the origin that initiated the request and the origin being requested. Four values are possible:

  • same-origin: the request comes from the exact same scheme, host, and port.
  • same-site: the request comes from the same registrable domain (eTLD+1 per the Public Suffix List), possibly a different subdomain or scheme.
  • cross-site: the request comes from a different site entirely.
  • none: there is no initiator origin; the user typed a URL, opened a bookmark, or the request was initiated in a context with no associated browsing context.

The browser computes this value based on the current page origin and the target URL. Because the header is forbidden, JavaScript running on evil.com cannot produce a request that arrives at the server carrying Sec-Fetch-Site: same-origin. The browser’s enforcement is the security guarantee, not application-level bookkeeping.

What the Datasette Change Does

Before PR #2689, Datasette used asgi-csrf, a signed double-submit cookie implementation. Every mutating form included a hidden field containing a token derived from a cookie value via HMAC. Every JavaScript fetch needed to read and forward that token as a header. Removing it requires touching templates, JavaScript, and middleware, which is precisely what the PR does.

The replacement logic is compact:

SAFE_SEC_FETCH_SITE_VALUES = {"same-origin", "same-site", "none"}

sec_fetch_site = request.headers.get("sec-fetch-site", "")

if sec_fetch_site in SAFE_SEC_FETCH_SITE_VALUES:
    pass  # Request is safe; proceed
elif sec_fetch_site == "cross-site":
    raise Forbidden("Cross-site request rejected")
else:
    # Header absent; fall back to Origin/Referer validation
    check_origin_or_referer(request)

The fallback for absent headers is why this is safe to ship. Non-browser clients, such as curl, Python scripts, or Datasette’s own test suite, do not send Sec-Fetch-Site at all. They fall through to Origin/Referer checking, which has been possible since long before Fetch Metadata existed. And non-browser clients cannot be CSRF victims by definition, because CSRF requires a browser with cookies and an active session.

Old browsers that predate Fetch Metadata support also fall through to the same path. Chrome added support in version 76 (August 2019), Firefox in version 90 (July 2021), and Safari in version 16.4 (March 2023). Global support for Sec-Fetch-Site sits above 96% as of 2026, so the fallback handles a small and shrinking minority of real traffic.

The same-site Edge Case

Accepting same-site as safe is the one decision worth scrutinizing. The value covers requests where the initiator and target share the same eTLD+1 per the Public Suffix List. A subdomain like attacker.example.com can send requests to app.example.com, and those requests carry Sec-Fetch-Site: same-site, not cross-site.

For most Datasette deployments, a data tool running on a domain you fully control, this is a reasonable position. You presumably trust the other subdomains of your own domain. The scenario where it matters is shared hosting, where multiple untrusted tenants share a domain. GitHub Pages handles this correctly: github.io appears in the Public Suffix List, so different users’ pages are treated as cross-site to each other. A naive shared-hosting arrangement where tenants share a domain not on the PSL would be vulnerable to this attack vector.

Google’s canonical Fetch Metadata resource isolation policy takes a stricter line, trusting only same-origin and none and rejecting same-site requests outright. That strictness makes sense for high-value targets and services with complex subdomain topologies. For Datasette, accepting same-site keeps things working for users who deploy across subdomains without introducing friction, and the threat model matches.

Where the Rest of the Ecosystem Stands

Django has not replaced its CSRF middleware with Fetch Metadata. The existing implementation uses an HMAC-signed double-submit cookie pattern, and there is active community discussion about whether to add Fetch Metadata as a supplemental or replacement layer. The conservative position is that Django needs to support a wide range of deployment environments, and backward compatibility in a general-purpose framework carries more weight than in a focused tool like Datasette.

Rails took a hybrid approach in version 7.2 (2024): protect_from_forgery with: :fetch_metadata is now a supported strategy that checks Sec-Fetch-Site and falls back to token verification when the header is absent. This gives applications an upgrade path without a forced cut.

Express.js has no official answer. The widely used csurf package was deprecated in 2023, with its maintainers explicitly citing Fetch Metadata headers as the modern replacement path, but no successor package has emerged with broad adoption. Most Node.js developers are writing their own middleware from scratch, often by referencing the same patterns described in the W3C spec.

Beyond CSRF

The full Fetch Metadata suite, comprising Sec-Fetch-Site, Sec-Fetch-Mode, Sec-Fetch-Dest, and Sec-Fetch-User, enables security policies that go well beyond CSRF. You can enforce that your API endpoint only accepts requests with Sec-Fetch-Mode: fetch, rejecting form submissions and top-level navigations at the header level. You can ensure your image endpoint only responds to Sec-Fetch-Dest: image requests, blocking hotlinking scenarios where the full response ends up somewhere unexpected. These checks operate before any application logic runs and cannot be spoofed by web content.

A full resource isolation policy combining all four headers looks something like this:

def is_allowed(request):
    site = request.headers.get('Sec-Fetch-Site', '')
    mode = request.headers.get('Sec-Fetch-Mode', '')
    dest = request.headers.get('Sec-Fetch-Dest', '')

    # Allow same-origin and direct navigation
    if site in ('same-origin', 'none'):
        return True

    # Allow simple navigational GET/HEAD (e.g., links from other sites)
    if mode == 'navigate' and request.method in ('GET', 'HEAD') and dest != 'object':
        return True

    return False

None of this was possible when the only trust signal was the Origin header plus CORS, both of which could be absent or misleading in edge cases.

What This Trade-off Actually Means

Token-based CSRF protection was always compensating for a browser capability gap. The browser knew where a request was coming from, but had no mechanism to communicate that to the server in a tamper-proof way. The Fetch Metadata specification moved that signal into the browser itself, where it cannot be overridden by web content.

The Datasette PR is a clean example of what it looks like to trust that mechanism. The implementation is smaller, the templates are simpler, the API is easier to use programmatically, and the security model is at least as strong for modern browsers while remaining sound for old ones through the fallback path. The trade-off is explicit: you are trusting browser enforcement rather than cryptographic tokens, and in exchange you get substantially lower operational overhead.

For any ASGI or WSGI application where you control the deployment context and your users are on reasonably modern browsers, the same calculation applies. Token-based CSRF is still a valid defense, but it is no longer the only defensible option, and for many applications it is no longer the simplest one.

Was this interesting?