If you run anything public on the internet in 2026, you’ve had the conversation. Someone asks: “how do I tell a real browser from a scraper without making my Safari users solve a captcha?” The popular answer is Sec-Fetch headers — the browser sets them automatically, JS can’t override them, surely bots can’t fake them. Right?
I ran 9 real stealth tools against a local echo server to find out. The short version: Sec-Fetch alone catches almost none of the modern ones. The leaks are elsewhere, they’re smaller, and catching them requires a layered approach that doesn’t start with blocking.
Here’s the actual data, the rules that follow from it, and the pipeline worth building.
Full code & test harness: Clone the 9-tool stealth harness used in this article at github.com/KingPin/sumguy-examples/tree/main/security/sec-fetch-detection
What Sec-Fetch and UA-CH Actually Are
Quick baseline so we’re speaking the same language.
Sec-Fetch-* is a family of request headers the browser attaches automatically:
Sec-Fetch-Site— relationship between the requesting page and the target (none,same-origin,same-site,cross-site)Sec-Fetch-Mode— the fetch mode:navigate,cors,no-cors,same-origin,websocketSec-Fetch-Dest— what the resource is for:document,script,style,image,empty, etc.Sec-Fetch-User—?1if the navigation was user-initiated (a click), absent otherwise
These are part of the Fetch Metadata Request Headers spec. The important property: they cannot be set by JavaScript running in the page. A hostile page on evil.com cannot forge Sec-Fetch-Site: same-origin. That’s a real security property — we’ll use it for CSRF defense later.
UA Client Hints (Sec-CH-UA) are Chrome/Chromium’s answer to the mess of User-Agent parsing:
Sec-CH-UA— brand list with versionsSec-CH-UA-Mobile—?1or?0Sec-CH-UA-Platform— OS string:"Windows","macOS","Linux","Android", etc.
This is a Chromium-only project. Firefox doesn’t ship it. Safari doesn’t ship it. Apple has shown no interest in implementing it. Keep that in the back of your mind — it becomes important when we talk about false positives.
The Harness
Setup was simple by design: a Node echo server on :8080 that logs every request header and returns them as JSON. Nine tools, each making a plain GET / navigation, captured headers compared.
The 9 tools:
- plain curl
- python-requests
- puppeteer-core (vanilla, no plugins)
- puppeteer-extra + puppeteer-extra-plugin-stealth
- playwright-core (vanilla)
- playwright + playwright-stealth
- undetected-chromedriver (Selenium)
- rebrowser-puppeteer-core
- curl-impersonate-chrome (TLS + header impersonation)
All Chromium-based tools ran the same headless Chromium 145 binary. Only the driver layer changed. That’s the apples-to-apples part — if something leaks, it’s the tool’s fault, not the underlying browser version.
One honest caveat: running against localhost means TLS fingerprinting (JA3/JA4) is moot. The echo server doesn’t terminate TLS. So this test is headers only. TLS fingerprinting is a separate — and harder — layer we’ll come back to at the end.
The Data: What Actually Leaks
Finding 1: Sec-Fetch doesn’t distinguish modern headless Chromium from real Chrome
Every Chromium-based tool — vanilla puppeteer, stealth, playwright, rebrowser, undetected-chromedriver — sent identical Sec-Fetch metadata on a navigation:
Sec-Fetch-Site: noneSec-Fetch-Mode: navigateSec-Fetch-Dest: documentSec-Fetch-User: ?1
On subresource requests (image, CSS, JS): Sec-Fetch-Site: same-origin, Sec-Fetch-Mode: no-cors, Sec-Fetch-Dest: image|style|script. Correct. No leak there either.
Headless Chromium generates these headers from the browser engine itself — the same code path as a user-facing Chrome. There’s no driver-level hook that changes them. If you’re blocking on Sec-Fetch alone, you’re blocking nothing that drives real Chromium.
Finding 2: The leaks are in UA-CH formatting and UA strings
Here’s the actual table:
| Tool | Sec-Fetch-Site | Sec-CH-UA | UA platform claim | Actual OS |
|---|---|---|---|---|
| puppeteer-vanilla | none | "Chromium";v="145", "Not:A-Brand";v="99" | Linux | Linux |
| puppeteer-stealth | none | " Not;A Brand";v="99", "Google Chrome";v="145", "Chromium";v="145" | Windows | Linux (lie) |
| playwright-vanilla | none | "Chromium";v="145", "Not:A-Brand";v="99" | Linux | Linux |
| playwright-stealth | none | "Chromium";v="145", "Not:A-Brand";v="99" | Linux | Linux |
| rebrowser-puppeteer | none | "Chromium";v="145", "Not:A-Brand";v="99" | Linux | Linux |
| undetected-chromedriver | none | "Not/A)Brand";v="99", "Chromium";v="148" | Linux | Linux |
| curl-impersonate-chrome | none | "Chromium";v="116", "Not)A;Brand";v="24", "Google Chrome";v="116" | Windows | Linux (lie) |
| plain curl | — | — | — | — |
| python-requests | — | — | — | — |
puppeteer-extra-plugin-stealth claims Sec-CH-UA-Platform: "Windows" while running on Linux. That’s the whole point of the plugin — it spoofs a Windows Chrome profile. But look at the GREASE token in its Sec-CH-UA: " Not;A Brand". Note the leading space. Real Chrome 145 doesn’t do that. The GREASE brand is supposed to look like garbage on purpose, but it follows a spec about how garbage it should look. Stealth plugins that hardcode a GREASE string and get the spacing wrong leave a quiet fingerprint.
curl-impersonate-chrome is the more interesting case. It impersonates Chrome’s TLS handshake and header order — that’s its whole value proposition. But it’s impersonating Chromium 116. In 2026. Chrome ships a new major version roughly every six weeks. If you have any sense of what Chrome’s current version is (which you can get from the Chrome release schedule, or just from observing real traffic), a version-116 handshake stands out like someone still wearing a 2022 lanyard at a 2026 conference.
undetected-chromedriver still ships HeadlessChrome/148.0.0.0 in the User-Agent string. After all that engineering to evade detection, the literal word “Headless” is right there in the UA. UC does have options to override this — it’s not a fixed limitation — but the defaults don’t apply them. A single grep for HeadlessChrome in your access logs will find more bots than any Sec-Fetch rule you write.
Plain curl and python-requests send no Sec-Fetch headers and no Sec-CH-UA. Trivial to filter. These are the easy path.
Finding 3: GREASE tokens are random, and that randomness is the point
The GREASE brand in Sec-CH-UA (the nonsense entry like "Not_A Brand";v="99") changes its punctuation per browser instance — that’s by design, to prevent sites from hardcoding matches against it. Real Chrome 145 might output "Not_A Brand", "Not:A-Brand", "Not(A Brand", etc. The spec defines what characters are valid in the garbage string.
Stealth tools that hardcode a single GREASE format get caught not because the string is wrong per se, but because it’s static when it should be variable within a defined character set. The defense isn’t looking for a specific string — it’s validating that the GREASE token matches the current spec’s valid format and that it changes across sessions.
False Positives First
Before writing a single blocking rule, commit this table to memory. These are legitimate clients that will trip every naive Sec-Fetch rule you write:
| Client | What it sends | Why |
|---|---|---|
| iOS Safari < 16.4 | No Sec-Fetch-* | Safari added Sec-Fetch in 16.4 (March 2023). Older devices still active. |
| Firefox (all versions) | No Sec-CH-UA-* | UA-CH is Chromium-only. Firefox sends Sec-Fetch, not UA-CH. |
| Safari (all versions) | No Sec-CH-UA-* | Apple has not implemented UA-CH. Not planned. |
| Tor / Mullvad Browser | Stripped headers | Privacy-by-design. Expected, not suspicious. |
| Googlebot, Bingbot | No Sec-Fetch-* | Fetchers, not browsers. Verify by reverse-DNS — never UA alone. |
| Social unfurlers (facebookexternalhit, Twitterbot, LinkedInBot, Slackbot, Discordbot, TelegramBot) | No Sec-Fetch-* | Preview cards. Allow these if you want link previews to work. |
| RSS readers (FreshRSS, Miniflux, NetNewsWire) | No Sec-Fetch-* | Standard HTTP libraries. These are your readers. |
| UptimeRobot, Better Uptime, Pingdom | No Sec-Fetch-* | Synthetic monitoring. Allowlist by source IP, not UA. |
| iMessage / Apple-PubSub | No Sec-Fetch-* | iMessage link previewer. |
API clients on /api/* | No Sec-Fetch-* | Legitimate by design. Don’t apply navigation rules here. |
And the Sec-Fetch-Site values that look suspicious but aren’t:
| Scenario | Header | Why |
|---|---|---|
| User pastes URL into address bar | Sec-Fetch-Site: none | Direct navigation. The most common entry path. Never block this. |
| Bookmark / open in new tab | Sec-Fetch-Site: none | Same. |
| OAuth callback / payment return | Sec-Fetch-Site: cross-site | Legit cross-origin redirects. |
| Click from Google search results | Sec-Fetch-Site: cross-site | A significant fraction of your inbound traffic. |
| Subdomain navigation | Sec-Fetch-Site: same-site | Normal on multi-subdomain sites. |
Five rules of thumb before anything else:
- Never block on a single missing header. Score it.
- Allowlist verified search-engine bots by reverse-DNS before any rule fires.
- API endpoints are not navigation. Sec-Fetch checks belong on HTML routes only — filter on
Accept: text/htmlorSec-Fetch-Dest: document. - Check your own analytics first. If 20% of your real traffic comes from Firefox and Safari, and your rule fires on missing UA-CH, you’re rate-limiting your readers, not bots.
Sec-Fetch-Site: noneis normal for direct navigation. Do not block it.
WAF Rules — With the Bypass Shipped Next to Each One
Every one of these rules is bypassable. I’m showing the bypass so you can make an informed decision about whether it’s worth deploying.
Caddy: Missing Sec-Fetch-Site on HTML navigation
@suspect_no_secfetch { method GET header Accept *text/html* not header Sec-Fetch-Site *}handle @suspect_no_secfetch { respond "Forbidden" 403}Bypass: Set Sec-Fetch-Site: same-origin manually. One header, one line. Any tool that reads this article is now immune.
Mitigation: Combine with a Referer same-origin check on POST. On GET, score-don’t-block. Use this as one signal, not the gate.
Caddy: Chrome UA without Sec-CH-UA
@chrome_ua_no_uach { header_regexp ua User-Agent "Chrome/[0-9]" not header Sec-Ch-Ua *}respond @chrome_ua_no_uach 403Bypass: curl-impersonate-chrome already sends matching Sec-CH-UA. puppeteer-extra-plugin-stealth does too. This rule catches curl and python-requests claiming a Chrome UA, which is already pretty dumb behavior.
Mitigation: Cross-check the brand-version in Sec-CH-UA against the major version in the User-Agent string. Mismatch is a score bump. A version-116 Sec-CH-UA paired with a Chrome/145 UA string is worth flagging.
Caddy: Mobile UA but desktop UA-CH mobile flag
@mobile_mismatch { header_regexp ua User-Agent "Mobile|Android|iPhone" header Sec-Ch-Ua-Mobile "?0"}respond @mobile_mismatch 403Bypass: Recent stealth tools sync the mobile flag with the UA string.
Mitigation: Pair with Sec-CH-UA-Platform. A UA string claiming iPhone but platform claiming Linux is much harder to fake correctly and consistently.
Nginx: Score-based map
map $http_sec_fetch_site $missing_secfetch { default 1; "" 0; "none" 0; "same-origin" 0; "same-site" 0; "cross-site" 0;}
map $http_accept $is_html_request { default 0; "~*text/html" 1;}
map "$missing_secfetch:$is_html_request" $block_request { default 0; "1:1" 1;}
server { if ($block_request) { return 403; }}Bypass: Spoof the header. Same as every other rule.
Mitigation: Feed $block_request into fail2ban for rate-limit-then-tarpit rather than outright 403. Score, don’t block. A single missing header from a real Safari user shouldn’t get them banned.
Coraza / ModSecurity: Scoring, not deny
This is the approach worth adopting if you run Coraza as a WAF middleware.
SecAction "id:9000,phase:1,pass,nolog,setvar:tx.bot_score=0"
SecRule REQUEST_HEADERS:Accept "@contains text/html" \ "id:9001,phase:1,chain,pass,setvar:tx.is_html=1" SecRule &REQUEST_HEADERS:Sec-Fetch-Site "@eq 0" \ "setvar:tx.bot_score=+5,msg:'missing Sec-Fetch-Site on HTML nav'"
SecRule REQUEST_HEADERS:User-Agent "@rx Chrome/[0-9]+" \ "id:9002,phase:1,chain,pass" SecRule &REQUEST_HEADERS:Sec-Ch-Ua "@eq 0" \ "setvar:tx.bot_score=+10,msg:'Chrome UA without Sec-CH-UA'"
SecRule REQUEST_HEADERS:User-Agent "@rx (iPhone|Android)" \ "id:9003,phase:1,chain,pass" SecRule REQUEST_HEADERS:Sec-Ch-Ua-Mobile "@streq ?0" \ "setvar:tx.bot_score=+10,msg:'mobile UA, desktop UA-CH'"
SecRule TX:BOT_SCORE "@ge 15" \ "id:9099,phase:1,deny,status:403,log,msg:'bot score threshold exceeded'"Why scoring beats blocks: Each rule individually is one-line bypassable. A tool that bypasses one still trips the others. Tune the threshold by watching access logs for a week. Start at 20, bring it down as you confirm zero false positives.
Cloudflare WAF Custom Rule (free tier)
(http.request.method eq "GET") and(http.request.uri.path contains "/") andnot (any(http.request.headers["sec-fetch-site"][*])) and(any(http.request.headers["accept"][*] contains "text/html"))Action: Managed Challenge, not Block. This preserves UX for the false positives that will inevitably trip it.
If you’re on Cloudflare Pro or above, their Bot Management score already factors Sec-Fetch into its calculation. Use their score. This rule is the free version.
Sec-Fetch as a CSRF Defense
This is the part most people skip and shouldn’t. For state-changing endpoints, this single rule in Caddy replaces a lot of token-passing plumbing:
@csrf_attempt { method POST PUT DELETE PATCH not header Sec-Fetch-Site "same-origin"}respond @csrf_attempt 403Why it works: a page on evil.com that auto-submits a form to your.site/api/transfer triggers Sec-Fetch-Site: cross-site. The browser sets this header and a hostile page running JS in the browser cannot override it — that’s the guarantee the spec provides.
Limitations you should know:
- Doesn’t protect against same-origin XSS. That’s a different fight entirely.
- Browsers without Sec-Fetch-Site (pre-Safari 16.4, any obscure user agent) need a CSRF token fallback.
- API clients (curl, mobile apps) on Bearer-token endpoints need an explicit allow path — they don’t send Sec-Fetch-Site and they’re legitimate.
MDN documents this pattern. It’s not novel — it’s just under-adopted. If you’re currently doing CSRF defense with a double-submit cookie pattern and you’re not on a public API, this is worth evaluating as a lighter alternative or complement.
The Pipeline: Sec-Fetch Screen → Score → Anubis PoW
Here’s the actual point. Don’t treat Sec-Fetch as the answer. Treat it as layer 1 of a pipeline:
Layer 1: Sec-Fetch / UA-CH heuristics — score the request at the WAF. Cheap, runs on every request, zero JS execution overhead. Catches the easy stuff: curl, python-requests, naive puppeteer that forgot to set headers.
Layer 2: Behavior — rate limit by IP and UA fingerprint bucket. Catches the slightly-less-naive bots hammering with the same fingerprint. A legit user doesn’t hit /api/products 800 times in 90 seconds.
Layer 3: Proof-of-work (Anubis) — for requests that scored suspicious at layer 1 and survived layer 2, gate them behind a JS PoW challenge. Real users sit through a 2-second hash computation. Headless tools without JS, or without a proper crypto.subtle implementation, fail. AI training scrapers in particular struggle with this — they’re often running lightweight HTTP clients, not full browser stacks, and PoW requires actual compute.
Layer 4: ASN reputation / managed challenge — for the persistent and well-resourced. Cloudflare-style block lists, known bad ASNs, datacenter IP ranges. This is your last line and you’re paying someone to maintain the intelligence.
Why the pipeline beats any single rule: each layer is individually bypassable. Bypassing one still leaves three others firing. More importantly, your legitimate false-positive traffic — Safari users, RSS readers, Googlebot — gets caught by allowlists early. They never reach layer 3. They never see a challenge.
Anubis, Xe Iaso’s OSS PoW gateway, is built specifically for the AI-scraper problem and pairs naturally with this approach. It’s designed to be bolted onto a reverse proxy. A dedicated SumGuy piece on deploying Anubis in front of a self-hosted stack is in the pipeline — consider this the setup reading.
The Honest 2026 Reality Check
Let me be direct about what this data actually says.
Sec-Fetch alone is a 2020-era idea. It catches curl and naive scripts that haven’t updated their tooling in four years. It does not catch anything that actually drives real Chromium, because real Chromium generates correct Sec-Fetch headers regardless of whether a human or a headless driver is behind it.
UA-CH is a Chromium-only project that hasn’t won. Firefox and Safari don’t ship it. That’s not a temporary situation — Apple has actively not implemented it and hasn’t signaled a change. If you build a detection strategy around UA-CH presence, you will systematically miss Firefox users and misidentify Safari users. Check your analytics before deploying UA-CH rules.
GREASE tokens are the actual fingerprint signal in the UA-CH space. Format is subtly inconsistent between stealth tools. The current generation that randomizes correctly within spec won’t be caught here. The older generation that hardcodes a specific GREASE string will.
The HeadlessChrome string is still in undetected-chromedriver’s UA by default. After all that engineering. A single grep in your access logs for HeadlessChrome will surface more bots than any sophisticated header-analysis rule you spend an afternoon writing. This is fixable on the tool side — it’s an awareness and default configuration problem, not a ceiling on what detection can catch.
TLS fingerprinting (JA3/JA4) is now the harder layer to spoof. curl-impersonate-chrome exists specifically because matching Chrome’s TLS handshake is where the real detection lives now. Headers are the easy part; the TLS handshake is harder to fake at scale because it’s deeper in the stack. A reverse proxy like Caddy or nginx can’t inspect it by default — you need something like nginx with the TLS fingerprint module, or Cloudflare, or a purpose-built security proxy.
The arms race has moved. In 2026, Sec-Fetch’s actual value is:
- Free CSRF defense on state-changing endpoints. Most underused win in this entire article. Five lines of Caddy config.
- Cheap filter for low-effort scrapers — the curl-and-requests crowd that represents probably 40% of your scraper traffic.
- A score input in a layered system. One data point among several, not a gate.
What to Actually Ship This Week
You don’t need to implement all of this today. Here’s the ordered list:
First: Add the Sec-Fetch-Site: same-origin check on POST/PUT/DELETE/PATCH. Free CSRF defense. Five lines. Do this before anything else. It’s the highest value-to-effort ratio on this page.
Second: Drop a scoring rule — not a block — for HTML navigation missing Sec-Fetch-Site combined with a Chrome UA missing Sec-CH-UA. Watch logs for a week before you do anything with the score.
Third: Allowlist verified bots by reverse-DNS before any rule fires. Googlebot specifically. Check the rDNS, then forward-confirm. This is annoying to set up and will save you an incident at 2 AM.
Fourth: Plan for Anubis at layer 3 if you’re seeing AI training scrapers in your access logs. The giveaway is bursts of systematic crawls hitting every internal link in sequence — a human never reads like that. Sec-Fetch won’t stop them. PoW will slow them enough to matter.
Fifth: Stop trusting User-Agent strings. Completely. Use them as a tiebreaker in a scoring system, not as a primary signal. If a stealth tool fixes the obvious tells you’re hunting for — and they do, on roughly a six-week cycle — UA strings become useless overnight. Behavior and PoW don’t age the same way.
The real bots running sophisticated Chromium automation are going to get through your header rules. That’s the honest state of things. The goal isn’t to stop every bot. It’s to make the economics work against the scraper: raise the cost of a successful crawl enough that your data isn’t worth the compute. Scoring plus PoW does that. Blocking on missing Sec-Fetch-Site does not.
Your 2 AM self, staring at access logs full of HeadlessChrome/148, will thank you for building the pipeline instead.