Skip to main content

Headers

Header order is part of your fingerprint, not just the values themselves. Chrome ships its headers in a fixed sequence: sec-ch-ua first, then sec-ch-ua-mobile, sec-ch-ua-platform, upgrade-insecure-requests, user-agent, accept, and so on down the list. Anti-bot vendors hash that sequence. Send the same set in a different order, add one Chrome would never emit, or skip one Chrome always sends, and the request stands out.

httpcloak bakes the canonical order into each preset. Your custom headers slot into preset-reserved positions, so adding Authorization or X-Anything-Custom lands at the offset Chrome would have used and the fingerprint stays intact.

tip

DevTools doesn't show you header order, so you're flying blind there. Hit tls.peet.ws/api/all and check the http2.sent_frames[].headers array. That's the wire order.

What ships by default

Every preset carries its own browser header set. For chrome-148-linux (today's default), the request goes out as:

PositionHeaderExample value
1sec-ch-ua"Chromium";v="148", "Google Chrome";v="148", "Not/A)Brand";v="99"
2sec-ch-ua-mobile?0
3sec-ch-ua-platform"Linux"
4upgrade-insecure-requests1
5user-agentMozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ...
6accepttext/html,application/xhtml+xml,...
7sec-fetch-sitenone
8sec-fetch-modenavigate
9sec-fetch-user?1
10sec-fetch-destdocument
11accept-encodinggzip, deflate, br, zstd
12accept-languageen-US,en;q=0.9
13priorityu=0, i

Different presets ship different defaults. Firefox skips sec-ch-ua-* entirely, Safari sends a different accept-language, mobile presets flip sec-ch-ua-mobile to ?1. The full list per preset lives in fingerprint/embedded/<preset>.json.

The lib also auto-rewrites the sec-fetch-* cluster based on the kind of request you're firing. POST/PUT/PATCH and most XHR-shaped GETs flip from navigation mode (navigate/document/?1) to CORS mode (cors/empty/cross-site, no sec-fetch-user). The browser does the same rewrite, so an API call doesn't go out with navigation-style headers attached.

Setting custom headers

Two scopes: per-request, or session-wide as a default.

Per-request

Drop a Headers map on the request. Whatever you set merges into the preset defaults. If your key matches a preset header, your value wins (single-value Set semantics).

package main

import (
"context"
"fmt"

httpcloak "github.com/sardanioss/httpcloak"
)

func main() {
s := httpcloak.NewSession("chrome-latest")
defer s.Close()

req := &httpcloak.Request{
Method: "GET",
URL: "https://httpbin.org/headers",
Headers: map[string][]string{
"X-My-Header": {"hello-world"},
"Authorization": {"Bearer xxx"},
},
}
resp, _ := s.Do(context.Background(), req)
defer resp.Close()

body, _ := resp.Text()
fmt.Println(body)
}

httpbin echoes back the headers it saw. You'll spot your X-My-Header: hello-world next to the full preset cluster: User-Agent, Accept, sec-ch-ua, and the rest.

Session-wide defaults

If a header should ride on every request in a session (auth tokens, an X-API-Key, a static Referer), set it once and leave it.

// Go has no built-in WithHeaders for session defaults.
// Closure-wrap the session and inject headers in your wrapper:
type apiClient struct {
s *httpcloak.Session
auth string
}

func (c *apiClient) Get(ctx context.Context, url string) (*httpcloak.Response, error) {
return c.s.Do(ctx, &httpcloak.Request{
Method: "GET",
URL: url,
Headers: map[string][]string{
"Authorization": {c.auth},
},
})
}

How merge works

Merge order is preset defaults first, your custom headers second. If your key collides with a preset key (case-insensitive), your value wins. New keys land at the position the preset reserved for them, or at the end if the preset doesn't reserve a slot.

The reserved-slot bit is what matters. The preset's full HPACK position table, separate from the smaller "always emit" set, carves out spots for situational headers like cache-control, content-type, content-length, cookie, origin, referer. So when you add Content-Type: application/json on a POST, it lands at the same offset Chrome would have placed it. Without that, your custom headers pile up after priority, which is the small kind of drift fingerprinters pick up on.

Things that don't behave the way you'd expect

  • Casing. HTTP/2 and HTTP/3 are lowercase on the wire, and the preset stores everything lowercase. Pass User-Agent: foo and the lib normalizes it to user-agent: foo for H2/H3. On HTTP/1.1, casing is preserved per the request map.
  • Removing a preset header. Set it to "" in your headers map and the lib won't emit it. Useful for dropping Accept-Encoding or similar defaults.
  • Custom headers vs each other. Five custom headers the preset doesn't reserve slots for all pile up at the end in the order you added them.
  • Cookie. Don't set Cookie directly unless you've thought it through. The session jar handles it. See Per-Request Cookies for the override path.

Inspecting what went out

The cleanest verification path is sending to tls.peet.ws/api/all and reading the http2.sent_frames array. Each HEADERS frame lists the headers in the exact order they hit the wire. That's ground truth.

httpbin.org/headers is fine for "did my custom header show up?" checks, but it returns a Python dict, not the wire order. For order, use peet.

Header order overrides

SetHeaderOrder(order []string) mutates the session's emit sequence at runtime. The next request through the session uses the new order on the wire. GetHeaderOrder() returns whatever's currently active, custom or preset-default. Pass nil or an empty slice to SetHeaderOrder and the session falls back to the preset's baked-in order, which is what you want most of the time.

This is the nuclear option. The preset's order is copied from a real browser capture, and any deviation from it is new fingerprint signal that the target can hash. Use this method only when you've confirmed (with peet output, with a captured PCAP, with vendor docs) that the target runs a header-order check no shipped preset matches. That situation is rare. For nearly every site, chrome-latest or firefox-148 or safari-18 already lines up.

Header names go in lowercase. HTTP/2 and HTTP/3 send field names lowercase on the wire, the preset stores them lowercase, the transport lowercases anything you pass anyway. Sticking to lowercase in your code keeps the surface boring and matches what tooling like peet shows. The current Chrome desktop order, copied from the table above, looks like this:

sec-ch-ua
sec-ch-ua-mobile
sec-ch-ua-platform
upgrade-insecure-requests
user-agent
accept
sec-fetch-site
sec-fetch-mode
sec-fetch-user
sec-fetch-dest
accept-encoding
accept-language
priority

Two situations come up in practice. First is rotating between known-good orders mid-session for adversarial probing: dropping into a stripped-down order to see whether the target actually checks order at all, or swapping a Chrome order for a Firefox order on the same TLS connection to test cross-fingerprint detection. Second is pinning an explicit order before a Save checkpoint when you want determinism on reload, since the stored config carries the preset name but the runtime override sits on the transport struct.

Heads up on persistence: the custom order is held in memory on the transport. Save / LoadSession round-trip the preset, cookies, TLS tickets, and ECH configs, but they don't currently serialize a custom header order. If you set a custom order, save the session, then load it, the session comes back on the preset's default. Re-apply your SetHeaderOrder call after LoadSession if you need the override to stick.

Custom orders don't disable the preset's HPACK position table for situational headers. The preset reserves slots for headers Chrome only emits some of the time (cache-control, content-type, content-length, cookie, origin, referer), and those slots stay live on top of whatever base order you set. So a custom order of [user-agent, accept, x-my-header] plus a POST with Content-Type: application/json and a Cookie from the jar still places content-type and cookie at the offsets the preset reserves for them. The custom order replaces the default base sequence, not the slot machinery underneath.

s := httpcloak.NewSession("chrome-latest")
defer s.Close()

s.SetHeaderOrder([]string{
"sec-ch-ua",
"sec-ch-ua-mobile",
"sec-ch-ua-platform",
"user-agent",
"accept",
"accept-language",
"accept-encoding",
"x-my-header",
})

current := s.GetHeaderOrder()
fmt.Println(current)

// Reset to preset default
s.SetHeaderOrder(nil)

Verify the result on tls.peet.ws/api/all. The http2.sent_frames[].headers array shows the exact order on the wire after your override, and that's the only place to confirm the change took effect.