RQ4

Cloudflare Workers normalizes Accept-Encoding before your handler sees it

June 15, 2026

If you're running a Cloudflare Worker and reading request.headers.get("accept-encoding") to inspect what the client sent, you're not actually seeing it. The Workers runtime normalizes Accept-Encoding to a fixed subset, typically br, gzip, before your handler runs. It strips zstd and deflate even when the client advertised them, and it adds br even when the client didn't. The original client value is preserved on a different field, request.cf.clientAcceptEncoding, but if you don't know that, you'll silently make decisions on the wrong data.

This affects detection code that checks for specific encodings (e.g., "real Chrome 100+ should send zstd"), custom compression-negotiation logic, and any analytics that record what compression clients support. We tripped over it while validating a bot-detection signal and saw the same behavior reproducibly on a free-plan Worker.

What this looks like in practice

A common scenario: a technical founder building a B2B SaaS on Cloudflare Workers writes a bot-detection rule in middleware. The rule reads request.headers.get("accept-encoding") and checks whether the value contains zstd. The reasoning: real Chrome 100+ always includes zstd in Accept-Encoding; if a request claims a recent Chrome User-Agent but Accept-Encoding lacks zstd, it's probably a Python library impersonating Chrome with stale header presets.

Tested locally with Postman. Sending a request with Accept-Encoding: gzip, br triggers the signal correctly (no zstd, Chrome UA → flagged). Sending Accept-Encoding: gzip, deflate, br, zstd does NOT trigger the signal. The rule looks right. Deployed.

Within an hour: 87% of legitimate traffic is being flagged as bot traffic. The signal is firing on every real Chrome browser. Real users are getting 403 responses to their signup attempts.

Spent two hours debugging. Eventually wrote a /debug endpoint that dumps JSON.stringify(Object.fromEntries(request.headers.entries())) next to JSON.stringify(request.cf). Visited the endpoint from a real Chrome browser. The accept-encoding field in headers shows br, gzip. The cf.clientAcceptEncoding field shows gzip, deflate, br, zstd.

Cloudflare normalizes Accept-Encoding to a fixed subset before the Worker handler sees it. The handler never gets the raw client value via request.headers; the original is preserved on the cf object.

The fix is one line: change request.headers.get("accept-encoding") to request.cf.clientAcceptEncoding. Re-deploy. False positive rate drops to 0%. Rule now flags actual curl_cffi-style traffic and lets real Chrome users through.

The whole episode took half a day to debug because nothing in the Cloudflare Workers docs mentioned the normalization at the time.

What Accept-Encoding is and why a Workers project might care

Accept-Encoding is an HTTP request header that tells the server which compression formats the client can decode. Real Chrome 100+ sends gzip, deflate, br, zstd. Firefox sends gzip, deflate, br, zstd as of v126. Older clients and most HTTP libraries send shorter lists or just gzip.

For a server, the header is normally consulted to decide which compression to apply on the response. For a Worker doing more than serve static files (bot detection, request inspection, custom routing), the exact value matters as a signal in its own right. A request claiming a Chrome User-Agent but lacking zstd in Accept-Encoding is one of the cleanest header-only signals for browser impersonation: real Chrome 100+ always includes zstd; impersonation libraries that don't track this detail emit gzip, br and out themselves.

Or so we thought.

The observation

We were testing a detection signal that fires when a request's User-Agent claims Chrome 100+ but Accept-Encoding lacks zstd. The signal kept firing on real Chrome browsers in production. The hypothesis (curl_cffi over-impersonating) didn't fit the data: the requests had real Chrome TLS fingerprints. So we dumped the raw headers as the Worker handler saw them.

Across observed inputs, the Worker handler consistently saw a normalized value regardless of what the client actually sent. A client sending the full gzip, deflate, br, zstd got br, gzip at the handler. A client sending only gzip got gzip, br. A client sending gzip, br got br, gzip. The handler-visible value never contained zstd or deflate. The order was reshuffled. And when the client didn't list br, Workers added it anyway.

The original client value, what was on the wire, is preserved on the request's cf object, specifically request.cf.clientAcceptEncoding. That field carries the byte-exact value the client sent, including zstd, deflate, ordering, and casing.

We have not bisected which Workers runtime version introduced this normalization. It's present today on free-plan Workers as of testing in 2026-06. The Workers documentation we found at the time did not mention it explicitly.

Why this matters for detection and content logic

Two failure modes affect real Workers code:

Bot detection that compares Accept-Encoding to UA claims. A check like "Chrome 100+ should send zstd" run against request.headers.get("accept-encoding") returns br, gzip for everyone, so the check fires for every real Chrome request, a 100% false positive rate. Read from request.cf.clientAcceptEncoding instead and the check works as intended.

Custom compression-negotiation logic in Worker handlers. If your Worker generates dynamic responses and picks a compression algorithm based on what the client supports, reading the normalized header tells you the client always supports gzip and br and never anything else. You'll never serve zstd to a zstd-capable client unless you read the cf field.

Less critical but still worth knowing: analytics that record client compression support will under-report zstd adoption if they sample from request.headers. The cf field gives accurate numbers.

How to read the original value

In a TypeScript Worker:

export default {
  fetch(request: Request): Response {
    const handlerVisible = request.headers.get("accept-encoding");
    const original = (request as any).cf?.clientAcceptEncoding;
    return Response.json({ handlerVisible, original });
  },
};

The cf object isn't fully typed in @cloudflare/workers-types for all properties, so the cast is sometimes needed. clientAcceptEncoding is a string or undefined.

Reproducing this

Deploy a minimal echo Worker (source available via verify/cloudflare-workers-accept-encoding-normalization.py --source or inline above). From any HTTP client, send requests with varied Accept-Encoding values:

curl -H "Accept-Encoding: gzip, deflate, br, zstd" https://your-worker.workers.dev/
curl -H "Accept-Encoding: gzip" https://your-worker.workers.dev/
curl -H "Accept-Encoding: identity" https://your-worker.workers.dev/

For each request, compare the returned headers_accept_encoding field against cf_clientAcceptEncoding. The handler-visible value should be normalized; the cf value should match what you sent.

Caveats

Related techniques

When inspecting incoming request data on Workers, treat request.headers as the post-normalization view. For values that need the original, the request.cf object often carries it under a different name. Examples we use elsewhere in our detection stack:

The general rule: request.cf is the source of truth for what Cloudflare's edge actually saw on the wire. request.headers is what the runtime decided your handler should work with.

Common questions

Why is my Accept-Encoding check firing on real Chrome users?

You're reading from request.headers.get("accept-encoding"), which returns the value AFTER Cloudflare Workers normalizes it. The runtime strips zstd and deflate before your handler runs. A real Chrome user sending gzip, deflate, br, zstd looks identical at the handler to a curl_cffi script sending gzip, br. Switch to reading request.cf.clientAcceptEncoding instead, that field preserves the original client value byte-for-byte.

Does this only happen on the free Cloudflare plan?

We tested on the free plan and observed the normalization. The behavior is part of the Workers runtime, not the plan tier, so it almost certainly applies to paid plans too. Anyone using a paid plan with a different observation should file a bug; we'd update this article. The same normalization is reproducible in wrangler dev --local, which suggests it's runtime-wide rather than edge-specific.

What other request headers does Workers normalize?

We tested User-Agent, Accept, Accept-Language, Sec-Fetch-*, and Cookie. All passed through unchanged. Accept-Encoding is the only header we've found that gets modified. That said, we haven't exhaustively tested every header, if you're depending on a specific header for a security-relevant check, log the value at both request.headers and any cf equivalent before trusting it.

Where in the Cloudflare docs is this documented?

We could not find official documentation of the Accept-Encoding normalization at the time of writing (2026-06). The cf.clientAcceptEncoding field is referenced in the Workers Request runtime API page but without explicit context for the normalization behavior. If you find it documented later, the docs link is the canonical reference; treat this article as the field-report version while it stays undocumented.

Does wrangler dev --local simulate this normalization or only production?

Both. We observed the same gzip, deflate, br, zstdbr, gzip transformation in wrangler dev --local and against the production probe.cliptrim.app Worker. If you want to test a header-based detection rule, the Local dev environment will reproduce the normalization correctly, you don't need to deploy to confirm the behavior.

I'm building a non-CF application that calls my Workers detection endpoint. Does the same gotcha apply?

Only if your application call hits a Cloudflare Worker. The normalization happens at the Workers runtime boundary, not at your application's edge. If your detection logic runs in non-CF infrastructure (Vercel Edge Functions, AWS Lambda@Edge, Fastly Compute, a regular Node server behind your own LB), request.headers shows whatever your runtime gives you, which may have its own quirks, but won't be this specific normalization.

Related reading

Stay updated

We publish on request fingerprinting, browser bot detection, and running it in production. Drop your email for new posts.