Skip to content
Fingerprinting

Header order depends on the request: how Chrome and Safari build each kind of request

A browser does not have one header set. It has one per kind of request, and an HTTP client that sends the same headers everywhere matches none of them.

Pim
Pim· Clearcote Research
10 min read

Key takeaways

  • Chrome 156 uses two client-hint layouts: document requests start with sec-ch-ua, sec-ch-ua-mobile, sec-ch-ua-platform before the User-Agent, subresources start with sec-ch-ua-platform, user-agent, sec-ch-ua, sec-ch-ua-mobile, and requests from a dedicated worker carry no client hints at all.
  • Chrome's priority header changes with the request (navigation u=0, i, blocking script u=1, image u=2, i, worker script u=4, i), is omitted when it equals the RFC 9218 defaults, and is never sent over HTTP/1.1.
  • iOS Safari 26 sends no client hints, starts every GET with sec-fetch-dest, ends with accept-language, priority, accept-encoding, and always states an explicit urgency.

One browser, many header sets

A page load is not one request. It is a navigation, then stylesheets, scripts, images, fetches, maybe an iframe, a worker and a form submission. The Fetch standard gives each a destination and a mode, and browsers build each request's headers from them: which headers appear, in what order, and with which values.

A server that terminates TLS sees all of this: the header list in wire order, over HTTP/2 the pseudo-header order and the stream priority, over HTTP/1.1 the casing. So the header set has to fit the kind of request it claims to be, not just the browser.

We measured Google Chrome 156 on Linux against a header-logging server over HTTP/2 and HTTP/1.1, loading a page that makes 15 kinds of request, and compared it with captures of Chrome loading a cross-site challenge widget. Both sources agree for every kind they share. For iOS Safari 26 we used a header profile recorded from a real device and published with an open-source solver.

Chrome 156, kind by kind

Document requests (typed navigation, iframe, form submission) open with the low-entropy client hints, then upgrade-insecure-requests, then the User-Agent, then accept and the Fetch Metadata headers. A typed navigation has sec-fetch-site: none and sec-fetch-user: ?1; an iframe or a script-submitted form has no sec-fetch-user, because only user-activated navigations send it.

Subresources (scripts, styles, images, fetch and XHR) use a different opening: sec-ch-ua-platform, user-agent, sec-ch-ua, sec-ch-ua-mobile, then accept. Some headers have fixed positions by kind:

  • origin is the first header on a CORS script or module, but comes after accept on a POST.
  • content-length is the first header on every POST, and a content-type set by script lands between sec-ch-ua and sec-ch-ua-mobile.
  • A fetch with cache: "no-cache" starts with cache-control: max-age=0; no-store starts with pragma: no-cache, cache-control: no-cache, exactly as the Fetch standard's cache-mode steps say.
  • sec-fetch-storage-access: active appears on cross-site credentialed requests and on requests from inside a cross-site iframe, and not on CORS-mode requests.

Worker requests are their own family. The worker script fetch starts with accept: */*, puts the User-Agent after the referrer, and has priority: u=4, i. Fetches made inside a dedicated worker carry no sec-ch-ua* headers at all, in both our measurement and the captures.

Accept and Priority per destination

Chrome 156 uses three distinct Accept values: one for documents (including image/jxl,image/avif,image/webp,image/apng and application/signed-exchange;v=b3;q=0.7), one for images (image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8) and text/css,*/*;q=0.1 for stylesheets. Everything else sends */*.

The priority header (RFC 9218) is derived from Chrome's internal request priority: navigation and iframe u=0, i, stylesheet u=0, blocking, module and CORS scripts u=1, fetch and XHR u=1, i, page images u=2, i, the worker script u=4, i. Chrome omits any part that equals the default, so a late image sends a bare i and an async script, whose priority equals both defaults, sends no header at all. Scripts and stylesheets are the only kinds without i. Over HTTP/1.1 Chrome sends no priority header for any request.

The HTTP/2 stream weight follows the same priority (we saw 256 for an iframe and 147 for an async script), while the connection preface is constant: 1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p on every connection we captured.

Safari does it differently everywhere

  • No client hints on any request.
  • Every GET kind starts with sec-fetch-dest and ends with accept-language, priority, accept-encoding; Chrome ends with accept-encoding, accept-language, priority.
  • Every request states an explicit urgency: u=0, i for documents, u=3, i for scripts and fetches, u=5, i for images, u=1, i for same-origin scripts.
  • The image Accept is a different list that ends with the Fetch standard's own default: image/webp,image/avif,image/jxl,image/heic,image/heic-sequence,video/*;q=0.8,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5.
  • POST requests and scripts after a redirect use orders that are shuffled relative to the GET skeleton.

A Safari User-Agent with sec-ch-ua headers, or a Chrome User-Agent with Safari's header tail, is a mismatch on the first request.

Common mistakes of impersonating clients

  • One header set for everything. Some popular impersonation wrappers hard-code a navigation set (sec-fetch-dest: document, sec-fetch-user: ?1, document Accept, priority: u=0, i) and send it on every request, including what should look like an image or an XHR.
  • Fetch Metadata that contradicts the request: navigate with an Origin and a text body, or sec-fetch-user on an iframe.
  • The wrong Accept for the destination, such as the document Accept on a script.
  • Priority that never changes, or a priority header on an HTTP/1.1 request that claims to be Chrome.
  • Casing on HTTP/1.1. Chrome mixes Title-Case standard headers with lowercase client hints and keeps script-set headers in the script's own casing. All-lowercase and all-Title-Case are both wrong.
  • Client hints in the wrong place or context: the document layout on subresources, or hints on worker requests.
  • Stale values: a document Accept without image/jxl no longer matches Chrome 156.

Libraries that impersonate TLS and HTTP/2 well usually leave per-request headers to the caller. Matching a browser means a table per request kind, not one profile.

The through-line

Header order is the network-side version of the rule that runs through the rest of this hub: every part of the session has to come from the same browser. A real browser produces the right set for each request because it builds them from the request's destination and mode; a client that copies one capture produces one request's set forever. See HTTP/2 fingerprinting for the connection layer and Chrome's version is not one number for the client-hint values themselves.

#fingerprinting#http2#reference

Clearcote puts this into practice

An open-source Chromium with fingerprint control compiled into the engine. A drop-in for Playwright & Puppeteer.

  • 228engine patches
  • 34,679lines added
  • 336Chromium files changed
  • 354checks per build

Engine numbers are for the Pro build; the free open build is a smaller, public patch set.

Free for one browser with GitHub. No card.