cdpfleet Docs GitHub Dashboard

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.

Google Chrome

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

WhatWhy
chrome, headless: falseReal Chrome on a virtual display: the reference header order.
locale: "de-DE"A context option; sets navigator.language and the Accept-Language header.
extraHTTPHeadersAdds or overrides headers on every request of a context.
page.route + route.continue({ headers })Rewrites each request's headers in flight.
tls.peet.wsEchoes the HTTP/2 HEADERS frame exactly as received — names in order — plus the TLS and Akamai HTTP/2 fingerprints.

How it works

  1. Launch one Chrome session.
  2. For each method, open a fresh context, apply it and load tls.peet.ws.
  3. Compare the header order, the Accept-Language value and the fingerprints.

The code

The same program in five languages (also on GitHub, with the raw output). Set these environment variables first:

// 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();
}

What we got

MethodAccept-LanguageHeader order (after the pseudo-headers)JA4
defaulten-US,en;q=0.9pragma, 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, priorityt13d1517h2_8daaf6152771_cb7bf5808d99
locale: de-DEde-DEpragma, 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, priorityt13d1517h2_8daaf6152771_cb7bf5808d99
extraHTTPHeadersde-DEpragma, 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, priorityt13d1517h2_8daaf6152771_cb7bf5808d99
route: rewrite headersen-US,en;q=0.9pragma, 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, priorityt13d1517h2_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