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:
originis the first header on a CORS script or module, but comes afteraccepton a POST.content-lengthis the first header on every POST, and acontent-typeset by script lands betweensec-ch-uaandsec-ch-ua-mobile.- A fetch with
cache: "no-cache"starts withcache-control: max-age=0;no-storestarts withpragma: no-cache, cache-control: no-cache, exactly as the Fetch standard's cache-mode steps say. sec-fetch-storage-access: activeappears 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-destand ends withaccept-language, priority, accept-encoding; Chrome ends withaccept-encoding, accept-language, priority. - Every request states an explicit urgency:
u=0, ifor documents,u=3, ifor scripts and fetches,u=5, ifor images,u=1, ifor 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:
navigatewith anOriginand a text body, orsec-fetch-useron an iframe. - The wrong Accept for the destination, such as the document Accept on a script.
- Priority that never changes, or a
priorityheader 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/jxlno 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.
