Articles › Security

CSRF protection without tokens: Sec-Fetch-Site, Origin and Go 1.25

Modern browsers tell the server whether a request came from another site. Check Sec-Fetch-Site and Origin instead of CSRF tokens: how Go 1.25 does it, and the caveats.

Intermediate 6 min read October 23, 2026

What you will learn
  • Every major browser since 2023 sends Sec-Fetch-Site on requests to HTTPS sites, so the server can tell directly whether a request is cross-origin
  • The rule: allow GET/HEAD/OPTIONS, allow Sec-Fetch-Site same-origin or none, without it compare Origin to Host, with neither header treat it as a non-browser client
  • Go 1.25 ships this as http.CrossOriginProtection; in other languages it is a dozen lines
  • It only works if GET never changes state and the site is HTTPS; keep SameSite cookies as a second layer

CSRF (cross-site request forgery) is another site tricking a user’s browser into sending a POST to your site, with the login cookie the browser attaches on its own. The classic defence is a secret token in every form, which works but has to be wired into every form and every fetch. Since Go 1.25 (August 2025) the Go standard library ships a defence with no tokens at all: it reads headers the browser itself sends about where a request came from. The same idea works in any language.

Checked against: the source of src/net/http/csrf.go and the net/http docs on pkg.go.dev, as of October 2026. The Go code here was not run locally (our test machine has Go 1.23); the Python code was run on Python 3.12.

The browser already tells you where the request came from

Every request a modern browser sends to an HTTPS site carries a Sec-Fetch-Site header with one of four values:

ValueMeaningExample
same-originExactly the same origin (scheme + host + port)A form on https://example.com posting to https://example.com/save
same-siteDifferent origin, same registrable domainhttps://blog.example.com to https://app.example.com
cross-siteA different site altogetherSomeone else’s page silently submitting a form to you
noneThe user started itTyped URL, bookmark

Headers starting with Sec- are forbidden headers: page JavaScript cannot set them, the browser does. A hostile page therefore cannot claim to be same-origin.

Filippo Valsorda, who designed the Go middleware, published the research behind it: all major browsers have sent Sec-Fetch-Site since 2023, and only to trustworthy origins, meaning HTTPS or localhost.

GET / HEAD / OPTIONSpass (must not change data)Origin on the trusted listpassSec-Fetch-Site: same-origin or nonepassNeither header presentpass (not a browser)Origin host equals Hostpass; anything else gets 403
Checked top to bottom; first match decides, otherwise 403

The five rules

The logic Go uses, which ports to any language, in order:

  1. GET, HEAD and OPTIONS always pass. They are treated as methods that do not change state.
  2. An Origin on your trusted list passes.
  3. If Sec-Fetch-Site is present, pass only same-origin or none; reject anything else.
  4. If neither Sec-Fetch-Site nor Origin is present, pass. It is not a browser (curl, mobile app, server-to-server).
  5. Only Origin present (older browser): pass when the host:port in Origin matches the Host header.

Rule 4 looks loose, but CSRF only exists because browsers attach cookies automatically. A non-browser client has no victim cookie to ride on.

Using it in Go 1.25

Wrap the handler in one line. A rejected request gets 403 Forbidden.

package main

import (
	"log"
	"net/http"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("POST /save", save)

	cop := http.NewCrossOriginProtection()
	// Allow specific other origins; the format must be scheme://host[:port]
	if err := cop.AddTrustedOrigin("https://admin.example.com"); err != nil {
		log.Fatal(err)
	}

	log.Fatal(http.ListenAndServe(":8080", cop.Handler(mux)))
}

func save(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("saved"))
}

Other methods worth knowing, per the net/http docs:

  • SetDenyHandler(h) replaces the plain 403 with your own handler, for a nicer error page or logging.
  • AddInsecureBypassPattern(pattern) exempts paths, using ServeMux pattern syntax. Meant for things like an SSO callback that must accept a cross-site POST. Keep it as narrow as possible.
  • Check(r) returns an error if the request should be rejected, for when you want to check without wrapping a handler.

Other languages: a dozen lines

Not on Go? Apply the same rules. Plain Python, no framework, callable from Flask, Django or FastAPI middleware:

from urllib.parse import urlsplit

SAFE = {"GET", "HEAD", "OPTIONS"}
TRUSTED = {"https://admin.example.com"}

def is_cross_origin_ok(method, headers, host):
    if method in SAFE:
        return True
    origin = headers.get("Origin", "")
    if origin in TRUSTED:
        return True
    site = headers.get("Sec-Fetch-Site", "")
    if site:
        return site in ("same-origin", "none")
    if not origin:
        return True  # neither header: not a browser
    return urlsplit(origin).netloc == host

Results from running it against sample requests (host = example.com):

RequestResult
POST, Sec-Fetch-Site: same-originallow
POST, cross-site, Origin https://evil.example403
POST, same-site, Origin https://blog.example.com403
POST, no Sec-Fetch-Site, Origin https://example.comallow
POST, no Sec-Fetch-Site, Origin https://evil.example403
POST, neither headerallow (not a browser)
GET, cross-siteallow (safe method)

Origin: null falls through to the last line and is rejected, which is right. OWASP warns never to put null on a trusted list.

Worked example: how many users lack Sec-Fetch-Site?

When Simon Willison wrote about this (October 2025), Can I Use put global support for Sec-Fetch-Site at about 94%. Take a site with 10,000 users:

  1. Share without support = 100% − 94% = 6%
  2. 10,000 × 0.06 = 600 users whose browser does not send the header
  3. Most of them still send Origin on a POST, so they fall to rule 5 and stay protected
  4. The real gap is very old browsers that send neither, such as Firefox 60–69, which Alex Edwards points out did not send Origin on POST

That number is why you keep more than one layer.

Sec-Fetch-Site is the main layer, Origin is the fallback, SameSite cookies are the net underneath. Remove any one and some group of users falls through.

Caveats before you rely on it

GET must never change state. The rules wave every GET through. If you have GET /logout or GET /delete?id=5, this does nothing for you. Audit your routes first: anything that writes must be POST/PUT/PATCH/DELETE.

It needs HTTPS. Browsers only send Sec-Fetch-Site to HTTPS or localhost. A plain-HTTP site is left with the Origin check alone.

Origin vs Host ignores the scheme. Rule 5 compares host:port only, so it cannot tell http://example.com from https://example.com. Filippo and Alex Edwards both recommend HSTS, which we already set in security headers on nginx.

Keep SameSite. Session cookies should be SameSite=Lax or Strict, plus Secure and HttpOnly. OWASP treats SameSite as defence in depth, not the main control, because Lax still sends cookies on top-level GET navigation.

Reverse proxies that rewrite Host. If nginx forwards a different Host than the user saw, rule 5 rejects legitimate requests. Set proxy_set_header Host $host; or add that origin to the trusted list.

Common mistakes

  • A GET that changes data, like an email link that deletes something or a logout link. Close this before anything else.
  • Blocking every request without Origin. Webhooks, curl and mobile apps all break. If you want stricter rules, separate header-token APIs from cookie-based routes.
  • AddInsecureBypassPattern("/") as a quick fix. That switches protection off for the whole site. Exempt only the paths that genuinely receive cross-site POSTs.
  • Assuming same-site is safe. A subdomain where others can upload HTML (a preview page, say) can send same-site requests. The rule blocks those; don’t reopen them.
  • Testing over HTTP on the machine’s IP and wondering why Sec-Fetch-Site is missing. Test on localhost or HTTPS.

If you log users in with passkeys, you still need CSRF protection: after login everything runs on a cookie as before.

Sources

FAQ

Can I drop CSRF tokens entirely?

For an HTTPS site whose users run current browsers, and where GET never changes state, the Sec-Fetch-Site plus Origin check covers the same ground as tokens. If you already have working tokens there is no rush to remove them; two layers are fine. New projects can start with this and skip instrumenting every form.

Why are requests from my own subdomain blocked?

blog.example.com and app.example.com are same-site but not same-origin. The rule only accepts same-origin, because sibling subdomains can have very different trust levels. If one really needs to post across, add that exact origin with AddTrustedOrigin.

Will mobile apps or curl calling my API be blocked?

No. Most non-browser clients send neither Sec-Fetch-Site nor Origin, and the rule lets those through. CSRF exists because browsers attach cookies automatically; an API like that should authenticate with a token in a header anyway.

Commands and settings were tested on sample systems. Try them on a test machine before production.