Cases / #9 · 2026-09-30 · Hard
Setting a header the wrong way: header order as a fingerprint
Four ways to change request headers in Playwright, and what each does to the header order a server sees. TLS and HTTP/2 stay put; the order doesn't.
Run on production on 2026-09-30: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
The problem
You need German content, or a tracing header on every request. Playwright gives you locale, extraHTTPHeaders and route.continue({ headers }). They all "work" — but browsers send headers in a fixed order, and some bot checks compare that order with the browser's real one. Which methods keep Chrome looking like Chrome?
What we used, and why
| What | Why |
|---|---|
chrome, headless: false | Real Chrome on a virtual display: the reference header order. |
locale: "de-DE" | A context option; sets navigator.language and the Accept-Language header. |
extraHTTPHeaders | Adds or overrides headers on every request of a context. |
page.route + route.continue({ headers }) | Rewrites each request's headers in flight. |
| tls.peet.ws | Echoes the HTTP/2 HEADERS frame exactly as received — names in order — plus the TLS and Akamai HTTP/2 fingerprints. |
How it works
- Launch one Chrome session.
- For each method, open a fresh context, apply it and load tls.peet.ws.
- Compare the header order, the
Accept-Languagevalue and the fingerprints.
The code
The same program in five languages (also on GitHub, with the raw output). Set these environment variables first:
CDPFLEET_API_KEY— your API key (dashboard → API keys)PROXY_URL— your proxy, e.g.http://user:[email protected]:8000
// npm install [email protected]
// env: CDPFLEET_API_KEY, PROXY_URL
import { chromium } from 'playwright';
const KEY = process.env.CDPFLEET_API_KEY;
const res = await fetch('https://starter.cdpfleet.com/chrome/session', {
method: 'POST',
headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
body: JSON.stringify({ proxy: process.env.PROXY_URL, headless: false }),
});
if (!res.ok) throw new Error(`launch: ${res.status} ${await res.text()}`);
const { wsUrl } = await res.json();
const browser = await chromium.connect(wsUrl, { headers: { 'x-api-key': KEY } });
// What the server saw: header names in order, and the HTTP/2 fingerprint.
async function observe(label, contextOptions, setup) {
const context = await browser.newContext(contextOptions);
const page = await context.newPage();
if (setup) await setup(page);
const fp = await (await page.goto('https://tls.peet.ws/api/all', { timeout: 60000 })).json();
await context.close();
const headers = fp.http2.sent_frames.find((f) => f.frame_type === 'HEADERS').headers;
return {
variant: label,
header_order: headers.map((h) => h.slice(0, h.indexOf(':', 1))),
accept_language: headers.find((h) => h.startsWith('accept-language: '))?.slice(17) ?? null,
akamai_h2: fp.http2.akamai_fingerprint,
ja4: fp.tls.ja4,
};
}
try {
const rows = [
await observe('default', {}),
await observe('locale: de-DE', { locale: 'de-DE' }),
await observe('extraHTTPHeaders', { extraHTTPHeaders: { 'accept-language': 'de-DE', 'x-request-id': 'abc123' } }),
await observe('route: rewrite headers', {}, (page) => page.route('**/*', (route) => route.continue({
headers: { ...route.request().headers(), 'x-request-id': 'abc123' },
}))),
];
console.log(JSON.stringify(rows, null, 2));
} finally {
await browser.close();
}
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL
import json
import os
import requests
from playwright.sync_api import sync_playwright
KEY = os.environ["CDPFLEET_API_KEY"]
res = requests.post("https://starter.cdpfleet.com/chrome/session", headers={"x-api-key": KEY},
json={"proxy": os.environ["PROXY_URL"], "headless": False}, timeout=60)
res.raise_for_status()
def observe(browser, label, context_options, setup=None):
"""What the server saw: header names in order, and the HTTP/2 fingerprint."""
context = browser.new_context(**context_options)
page = context.new_page()
if setup:
setup(page)
fp = page.goto("https://tls.peet.ws/api/all", timeout=60000).json()
context.close()
headers = next(f for f in fp["http2"]["sent_frames"] if f["frame_type"] == "HEADERS")["headers"]
return {
"variant": label,
"header_order": [h[:h.index(":", 1)] for h in headers],
"accept_language": next((h[17:] for h in headers if h.startswith("accept-language: ")), None),
"akamai_h2": fp["http2"]["akamai_fingerprint"],
"ja4": fp["tls"]["ja4"],
}
def rewrite(page):
page.route("**/*", lambda route: route.continue_(headers={**route.request.headers, "x-request-id": "abc123"}))
with sync_playwright() as p:
browser = p.chromium.connect(res.json()["wsUrl"], headers={"x-api-key": KEY})
try:
print(json.dumps([
observe(browser, "default", {}),
observe(browser, "locale: de-DE", {"locale": "de-DE"}),
observe(browser, "extraHTTPHeaders", {"extra_http_headers": {"accept-language": "de-DE", "x-request-id": "abc123"}}),
observe(browser, "route: rewrite headers", {}, rewrite),
], indent=2))
finally:
browser.close()
// Maven: com.microsoft.playwright:playwright:1.60.0, com.google.code.gson:gson:2.11.0
// Run with PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1. env: CDPFLEET_API_KEY, PROXY_URL
import com.google.gson.*;
import com.microsoft.playwright.*;
import java.net.URI;
import java.net.http.*;
import java.util.*;
import java.util.function.Consumer;
public class Main {
static final String KEY = System.getenv("CDPFLEET_API_KEY");
// What the server saw: header names in order, and the HTTP/2 fingerprint.
static JsonObject observe(Browser browser, String label, Browser.NewContextOptions options, Consumer<Page> setup) {
BrowserContext context = browser.newContext(options);
Page page = context.newPage();
if (setup != null) setup.accept(page);
JsonObject fp = JsonParser.parseString(page.navigate("https://tls.peet.ws/api/all",
new Page.NavigateOptions().setTimeout(60000)).text()).getAsJsonObject();
context.close();
JsonArray order = new JsonArray();
String acceptLanguage = null;
for (JsonElement f : fp.getAsJsonObject("http2").getAsJsonArray("sent_frames")) {
if (!f.getAsJsonObject().get("frame_type").getAsString().equals("HEADERS")) continue;
for (JsonElement h : f.getAsJsonObject().getAsJsonArray("headers")) {
String line = h.getAsString();
order.add(line.substring(0, line.indexOf(':', 1)));
if (line.startsWith("accept-language: ")) acceptLanguage = line.substring(17);
}
}
JsonObject row = new JsonObject();
row.addProperty("variant", label);
row.add("header_order", order);
row.addProperty("accept_language", acceptLanguage);
row.add("akamai_h2", fp.getAsJsonObject("http2").get("akamai_fingerprint"));
row.add("ja4", fp.getAsJsonObject("tls").get("ja4"));
return row;
}
public static void main(String[] args) throws Exception {
String body = "{\"proxy\": " + new Gson().toJson(System.getenv("PROXY_URL")) + ", \"headless\": false}";
HttpResponse<String> res = HttpClient.newHttpClient().send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/chrome/session"))
.header("x-api-key", KEY).header("content-type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) throw new RuntimeException("launch: " + res.statusCode() + " " + res.body());
String wsUrl = JsonParser.parseString(res.body()).getAsJsonObject().get("wsUrl").getAsString();
try (Playwright playwright = Playwright.create()) {
Browser browser = playwright.chromium().connect(wsUrl, new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
try {
JsonArray rows = new JsonArray();
rows.add(observe(browser, "default", new Browser.NewContextOptions(), null));
rows.add(observe(browser, "locale: de-DE", new Browser.NewContextOptions().setLocale("de-DE"), null));
rows.add(observe(browser, "extraHTTPHeaders", new Browser.NewContextOptions()
.setExtraHTTPHeaders(Map.of("accept-language", "de-DE", "x-request-id", "abc123")), null));
rows.add(observe(browser, "route: rewrite headers", new Browser.NewContextOptions(), page -> page.route("**/*", route -> {
Map<String, String> headers = new HashMap<>(route.request().headers());
headers.put("x-request-id", "abc123");
route.resume(new Route.ResumeOptions().setHeaders(headers));
})));
System.out.println(new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create().toJson(rows));
} finally {
browser.close();
}
}
}
}
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL
using System.Net.Http.Json;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Playwright;
var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
var res = await http.PostAsJsonAsync("https://starter.cdpfleet.com/chrome/session",
new { proxy = Environment.GetEnvironmentVariable("PROXY_URL"), headless = false });
if (!res.IsSuccessStatusCode) throw new Exception($"launch: {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");
var wsUrl = (await res.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("wsUrl").GetString()!;
using var playwright = await Playwright.CreateAsync();
var browser = await playwright.Chromium.ConnectAsync(wsUrl, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
// What the server saw: header names in order, and the HTTP/2 fingerprint.
async Task<JsonObject> Observe(string label, BrowserNewContextOptions options, Func<IPage, Task>? setup = null)
{
var context = await browser.NewContextAsync(options);
var page = await context.NewPageAsync();
if (setup != null) await setup(page);
var fp = JsonNode.Parse(await (await page.GotoAsync("https://tls.peet.ws/api/all", new() { Timeout = 60000 }))!.TextAsync())!;
await context.CloseAsync();
var headers = fp["http2"]!["sent_frames"]!.AsArray().First(f => (string?)f!["frame_type"] == "HEADERS")!["headers"]!.AsArray()
.Select(h => (string)h!).ToList();
return new JsonObject
{
["variant"] = label,
["header_order"] = new JsonArray(headers.Select(h => (JsonNode?)h[..h.IndexOf(':', 1)]).ToArray()),
["accept_language"] = headers.FirstOrDefault(h => h.StartsWith("accept-language: "))?[17..],
["akamai_h2"] = fp["http2"]!["akamai_fingerprint"]!.DeepClone(),
["ja4"] = fp["tls"]!["ja4"]!.DeepClone(),
};
}
try
{
var rows = new JsonArray
{
await Observe("default", new()),
await Observe("locale: de-DE", new() { Locale = "de-DE" }),
await Observe("extraHTTPHeaders", new() { ExtraHTTPHeaders = new Dictionary<string, string> { ["accept-language"] = "de-DE", ["x-request-id"] = "abc123" } }),
await Observe("route: rewrite headers", new(), page => page.RouteAsync("**/*", route =>
{
var headers = new Dictionary<string, string>(route.Request.Headers) { ["x-request-id"] = "abc123" };
return route.ContinueAsync(new() { Headers = headers });
})),
};
Console.WriteLine(rows.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
}
finally
{
await browser.CloseAsync();
}
// go get github.com/playwright-community/[email protected]
// Driver: build playwright-core 1.60.0 from npm and set PLAYWRIGHT_DRIVER_PATH (see /docs/quickstart).
// env: CDPFLEET_API_KEY, PROXY_URL
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"github.com/playwright-community/playwright-go"
)
var key = os.Getenv("CDPFLEET_API_KEY")
func launch(name string, options map[string]any) (map[string]any, error) {
body, _ := json.Marshal(options)
req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/"+name+"/session", bytes.NewReader(body))
req.Header.Set("x-api-key", key)
req.Header.Set("content-type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(res.Body)
return nil, fmt.Errorf("launch %s: %s %s", name, res.Status, msg)
}
var session map[string]any
return session, json.NewDecoder(res.Body).Decode(&session)
}
type row struct {
Variant string `json:"variant"`
HeaderOrder []string `json:"header_order"`
AcceptLanguage *string `json:"accept_language"`
AkamaiH2 string `json:"akamai_h2"`
JA4 string `json:"ja4"`
}
// What the server saw: header names in order, and the HTTP/2 fingerprint.
func observe(browser playwright.Browser, label string, options playwright.BrowserNewContextOptions, setup func(playwright.Page)) row {
context, err := browser.NewContext(options)
if err != nil {
log.Fatal(err)
}
page, _ := context.NewPage()
if setup != nil {
setup(page)
}
res, err := page.Goto("https://tls.peet.ws/api/all", playwright.PageGotoOptions{Timeout: playwright.Float(60000)})
if err != nil {
log.Fatal(err)
}
var fp struct {
TLS struct {
JA4 string `json:"ja4"`
} `json:"tls"`
HTTP2 struct {
AkamaiFingerprint string `json:"akamai_fingerprint"`
SentFrames []struct {
FrameType string `json:"frame_type"`
Headers []string `json:"headers"`
} `json:"sent_frames"`
} `json:"http2"`
}
res.JSON(&fp)
context.Close()
r := row{Variant: label, HeaderOrder: []string{}, AkamaiH2: fp.HTTP2.AkamaiFingerprint, JA4: fp.TLS.JA4}
for _, f := range fp.HTTP2.SentFrames {
if f.FrameType != "HEADERS" {
continue
}
for _, h := range f.Headers {
r.HeaderOrder = append(r.HeaderOrder, h[:strings.Index(h[1:], ":")+1])
if strings.HasPrefix(h, "accept-language: ") {
v := h[17:]
r.AcceptLanguage = &v
}
}
}
return r
}
func main() {
session, err := launch("chrome", map[string]any{"proxy": os.Getenv("PROXY_URL"), "headless": false})
if err != nil {
log.Fatal(err)
}
pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
if err != nil {
log.Fatal(err)
}
defer pw.Stop()
browser, err := pw.Chromium.Connect(session["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
if err != nil {
log.Fatal(err)
}
defer browser.Close()
rows := []row{
observe(browser, "default", playwright.BrowserNewContextOptions{}, nil),
observe(browser, "locale: de-DE", playwright.BrowserNewContextOptions{Locale: playwright.String("de-DE")}, nil),
observe(browser, "extraHTTPHeaders", playwright.BrowserNewContextOptions{
ExtraHttpHeaders: map[string]string{"accept-language": "de-DE", "x-request-id": "abc123"}}, nil),
observe(browser, "route: rewrite headers", playwright.BrowserNewContextOptions{}, func(page playwright.Page) {
page.Route("**/*", func(route playwright.Route) {
headers := route.Request().Headers()
headers["x-request-id"] = "abc123"
route.Continue(playwright.RouteContinueOptions{Headers: headers})
})
}),
}
out, _ := json.MarshalIndent(rows, "", " ")
fmt.Println(string(out))
}
What we got
| Method | Accept-Language | Header order (after the pseudo-headers) | JA4 |
|---|---|---|---|
| default | en-US,en;q=0.9 | pragma, cache-control, 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 | t13d1517h2_8daaf6152771_cb7bf5808d99 |
| locale: de-DE | de-DE | pragma, cache-control, sec-ch-ua, sec-ch-ua-mobile, sec-ch-ua-platform, upgrade-insecure-requests, user-agent, accept-language, accept, sec-fetch-site, sec-fetch-mode, sec-fetch-user, sec-fetch-dest, accept-encoding, priority | t13d1517h2_8daaf6152771_cb7bf5808d99 |
| extraHTTPHeaders | de-DE | pragma, cache-control, sec-ch-ua, sec-ch-ua-mobile, sec-ch-ua-platform, upgrade-insecure-requests, user-agent, accept-language, x-request-id, accept, sec-fetch-site, sec-fetch-mode, sec-fetch-user, sec-fetch-dest, accept-encoding, priority | t13d1517h2_8daaf6152771_cb7bf5808d99 |
| route: rewrite headers | en-US,en;q=0.9 | pragma, cache-control, accept, upgrade-insecure-requests, user-agent, x-request-id, sec-ch-ua, sec-ch-ua-mobile, sec-ch-ua-platform, sec-fetch-site, sec-fetch-mode, sec-fetch-user, sec-fetch-dest, accept-encoding, accept-language, priority | t13d1517h2_8daaf6152771_cb7bf5808d99 |
From the Node.js run on 2026-09-30. IP addresses are replaced with placeholders (203.0.113.x); equal addresses stay equal. The other languages produced the same findings.
Raw output (Node.js)
[
{
"variant": "default",
"header_order": [
":method",
":authority",
":scheme",
":path",
"pragma",
"cache-control",
"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"
],
"accept_language": "en-US,en;q=0.9",
"akamai_h2": "1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p",
"ja4": "t13d1517h2_8daaf6152771_cb7bf5808d99"
},
{
"variant": "locale: de-DE",
"header_order": [
":method",
":authority",
":scheme",
":path",
"pragma",
"cache-control",
"sec-ch-ua",
"sec-ch-ua-mobile",
"sec-ch-ua-platform",
"upgrade-insecure-requests",
"user-agent",
"accept-language",
"accept",
"sec-fetch-site",
"sec-fetch-mode",
"sec-fetch-user",
"sec-fetch-dest",
"accept-encoding",
"priority"
],
"accept_language": "de-DE",
"akamai_h2": "1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p",
"ja4": "t13d1517h2_8daaf6152771_cb7bf5808d99"
},
{
"variant": "extraHTTPHeaders",
"header_order": [
":method",
":authority",
":scheme",
":path",
"pragma",
"cache-control",
"sec-ch-ua",
"sec-ch-ua-mobile",
"sec-ch-ua-platform",
"upgrade-insecure-requests",
"user-agent",
"accept-language",
"x-request-id",
"accept",
"sec-fetch-site",
"sec-fetch-mode",
"sec-fetch-user",
"sec-fetch-dest",
"accept-encoding",
"priority"
],
"accept_language": "de-DE",
"akamai_h2": "1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p",
"ja4": "t13d1517h2_8daaf6152771_cb7bf5808d99"
},
{
"variant": "route: rewrite headers",
"header_order": [
":method",
":authority",
":scheme",
":path",
"pragma",
"cache-control",
"accept",
"upgrade-insecure-requests",
"user-agent",
"x-request-id",
"sec-ch-ua",
"sec-ch-ua-mobile",
"sec-ch-ua-platform",
"sec-fetch-site",
"sec-fetch-mode",
"sec-fetch-user",
"sec-fetch-dest",
"accept-encoding",
"accept-language",
"priority"
],
"accept_language": "en-US,en;q=0.9",
"akamai_h2": "1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p",
"ja4": "t13d1517h2_8daaf6152771_cb7bf5808d99"
}
]Takeaways
- TLS (JA4) and the Akamai HTTP/2 fingerprint never change — they come from the network stack, not from headers.
- Chrome's own order puts
accept-languagenear the end, afteraccept-encoding. BothlocaleandextraHTTPHeadersmove it up, right afteruser-agent— an order real Chrome doesn't send on a navigation. extraHTTPHeadersalso sends exactly what you wrote:de-DEinstead of Chrome's ownde-DE,de;q=0.9.route.continue({ headers })rewrites the order entirely:acceptjumps up, client hints move afteruser-agent, custom headers land in the middle. It's the most visible of all.- Best: don't touch headers on navigations if a site checks them. For the language, prefer a launch option the browser applies itself (e.g. Camoufox's
locale, see the Camoufox persona case) — and check the result against tls.peet.ws the same way as here.