Cases / #1 · 2026-09-30 · Medium
What your browser says before it says anything: TLS and HTTP/2 fingerprints
Five engines, one endpoint (tls.peet.ws), and the fingerprints anti-bot systems read before your page even loads.
Google Chrome Microsoft Edge Firefox Camoufox WebKit
Run on production on 2026-09-30: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
The problem
Anti-bot systems don't wait for JavaScript. The first thing a server sees is the TLS ClientHello (cipher suites, extensions, their order) and, on HTTP/2, the SETTINGS and WINDOW_UPDATE frames. Those are condensed into fingerprints — JA3/JA4 for TLS, the Akamai HTTP/2 fingerprint for h2 — and compared with what the user agent claims to be. A Chrome user agent over a Python-requests TLS stack is flagged instantly. So: what does each cdpfleet engine actually send?
What we used, and why
| What | Why |
|---|---|
chrome, edge, firefox, camoufox, webkit | One of each engine family, to compare Chromium, Gecko and WebKit network stacks. |
proxy | Every session needs one; fingerprints are made by the browser, not the proxy, so any proxy works. |
headless: true | TLS and HTTP/2 don't change between headless and headful — the cheapest mode (1 thread) is enough. |
page.goto(tls.peet.ws/api/all) | The browser itself makes the request. page.request / APIRequest would go out through Playwright's own HTTP client, with a different fingerprint. |
How it works
- Launch each browser with a proxy.
- Navigate to
https://tls.peet.ws/api/alland read the JSON the page returns. - Keep the user agent, HTTP version, JA4, JA3 hash, peetprint and Akamai HTTP/2 fingerprint.
- Close the browser — that ends the session and the billing.
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 (http://user:pass@host:port)
import { chromium, firefox, webkit } from 'playwright';
const KEY = process.env.CDPFLEET_API_KEY;
const PROXY = process.env.PROXY_URL;
// One browser per engine family. `family` is the Playwright client that speaks to it.
const BROWSERS = [
{ name: 'chrome', family: chromium },
{ name: 'edge', family: chromium },
{ name: 'firefox', family: firefox },
{ name: 'camoufox', family: firefox },
{ name: 'webkit', family: webkit },
];
async function launch(name, options) {
const res = await fetch(`https://starter.cdpfleet.com/${name}/session`, {
method: 'POST',
headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
body: JSON.stringify(options),
});
if (!res.ok) throw new Error(`launch ${name}: ${res.status} ${await res.text()}`);
return res.json();
}
// Navigate the browser itself to tls.peet.ws: the JSON it returns describes the TLS
// ClientHello and HTTP/2 frames this very browser sent. (page.request would go out from
// Playwright's own HTTP client instead, with a different fingerprint.)
// A residential exit occasionally times out: one retry, in a fresh tab.
async function fingerprint(browser) {
for (let attempt = 1; ; attempt++) {
try {
const page = await browser.newPage();
return await (await page.goto('https://tls.peet.ws/api/all', { timeout: 30000 })).json();
} catch (err) {
if (attempt === 2) throw err;
}
}
}
const rows = [];
for (const b of BROWSERS) {
const session = await launch(b.name, { proxy: PROXY, headless: true });
const browser = await b.family.connect(session.wsUrl, { headers: { 'x-api-key': KEY } });
try {
const fp = await fingerprint(browser);
rows.push({
browser: b.name,
version: browser.version(),
user_agent: fp.user_agent,
http_version: fp.http_version,
ja4: fp.tls.ja4,
ja3_hash: fp.tls.ja3_hash,
peetprint_hash: fp.tls.peetprint_hash,
akamai_h2: fp.http2?.akamai_fingerprint ?? null,
akamai_h2_hash: fp.http2?.akamai_fingerprint_hash ?? null,
cipher_suites: fp.tls.ciphers.length,
extensions: fp.tls.extensions.length,
});
} finally {
await browser.close(); // ends the session and stops billing
}
}
console.log(JSON.stringify(rows, null, 2));
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL (http://user:pass@host:port)
import json
import os
import requests
from playwright.sync_api import sync_playwright
KEY = os.environ["CDPFLEET_API_KEY"]
PROXY = os.environ["PROXY_URL"]
# One browser per engine family, and the Playwright client that speaks to it.
BROWSERS = [("chrome", "chromium"), ("edge", "chromium"), ("firefox", "firefox"),
("camoufox", "firefox"), ("webkit", "webkit")]
def launch(name, options):
res = requests.post(f"https://starter.cdpfleet.com/{name}/session",
headers={"x-api-key": KEY}, json=options, timeout=60)
res.raise_for_status()
return res.json()
def fingerprint(browser):
# Navigate the browser itself: the JSON describes the TLS ClientHello and HTTP/2
# frames this very browser sent (page.request would use Playwright's own client).
# A residential exit occasionally times out: one retry, in a fresh tab.
for attempt in (1, 2):
try:
return browser.new_page().goto("https://tls.peet.ws/api/all", timeout=30000).json()
except Exception:
if attempt == 2:
raise
rows = []
with sync_playwright() as p:
for name, family in BROWSERS:
session = launch(name, {"proxy": PROXY, "headless": True})
browser = getattr(p, family).connect(session["wsUrl"], headers={"x-api-key": KEY})
try:
fp = fingerprint(browser)
http2 = fp.get("http2") or {}
rows.append({
"browser": name,
"version": browser.version,
"user_agent": fp["user_agent"],
"http_version": fp["http_version"],
"ja4": fp["tls"]["ja4"],
"ja3_hash": fp["tls"]["ja3_hash"],
"peetprint_hash": fp["tls"]["peetprint_hash"],
"akamai_h2": http2.get("akamai_fingerprint"),
"akamai_h2_hash": http2.get("akamai_fingerprint_hash"),
"cipher_suites": len(fp["tls"]["ciphers"]),
"extensions": len(fp["tls"]["extensions"]),
})
finally:
browser.close() # ends the session and stops billing
print(json.dumps(rows, indent=2))
// 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.Map;
public class Main {
static final String KEY = System.getenv("CDPFLEET_API_KEY");
static final HttpClient HTTP = HttpClient.newHttpClient();
static JsonObject launch(String name, JsonObject options) throws Exception {
HttpResponse<String> res = HTTP.send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/" + name + "/session"))
.header("x-api-key", KEY).header("content-type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(options.toString())).build(), HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) throw new RuntimeException("launch " + name + ": " + res.statusCode() + " " + res.body());
return JsonParser.parseString(res.body()).getAsJsonObject();
}
// A residential exit occasionally times out: one retry, in a fresh tab.
static JsonObject fingerprint(Browser browser) {
for (int attempt = 1; ; attempt++) {
try {
Response res = browser.newPage().navigate("https://tls.peet.ws/api/all", new Page.NavigateOptions().setTimeout(30000));
return JsonParser.parseString(res.text()).getAsJsonObject();
} catch (PlaywrightException err) {
if (attempt == 2) throw err;
}
}
}
public static void main(String[] args) throws Exception {
// One browser per engine family, and the Playwright client that speaks to it.
String[][] browsers = {{"chrome", "chromium"}, {"edge", "chromium"}, {"firefox", "firefox"}, {"camoufox", "firefox"}, {"webkit", "webkit"}};
JsonArray rows = new JsonArray();
try (Playwright playwright = Playwright.create()) {
for (String[] b : browsers) {
JsonObject options = new JsonObject();
options.addProperty("proxy", System.getenv("PROXY_URL"));
options.addProperty("headless", true);
JsonObject session = launch(b[0], options);
BrowserType family = switch (b[1]) { case "firefox" -> playwright.firefox(); case "webkit" -> playwright.webkit(); default -> playwright.chromium(); };
Browser browser = family.connect(session.get("wsUrl").getAsString(), new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
try {
// Navigate the browser itself: the JSON describes the TLS ClientHello and HTTP/2
// frames this very browser sent (APIRequest would use Playwright's own client).
JsonObject fp = fingerprint(browser);
JsonObject tls = fp.getAsJsonObject("tls");
JsonObject h2 = fp.has("http2") && fp.get("http2").isJsonObject() ? fp.getAsJsonObject("http2") : new JsonObject();
JsonObject row = new JsonObject();
row.addProperty("browser", b[0]);
row.addProperty("version", browser.version());
row.add("user_agent", fp.get("user_agent"));
row.add("http_version", fp.get("http_version"));
row.add("ja4", tls.get("ja4"));
row.add("ja3_hash", tls.get("ja3_hash"));
row.add("peetprint_hash", tls.get("peetprint_hash"));
row.add("akamai_h2", h2.has("akamai_fingerprint") ? h2.get("akamai_fingerprint") : JsonNull.INSTANCE);
row.add("akamai_h2_hash", h2.has("akamai_fingerprint_hash") ? h2.get("akamai_fingerprint_hash") : JsonNull.INSTANCE);
row.addProperty("cipher_suites", tls.getAsJsonArray("ciphers").size());
row.addProperty("extensions", tls.getAsJsonArray("extensions").size());
rows.add(row);
} finally {
browser.close(); // ends the session and stops billing
}
}
}
System.out.println(new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create().toJson(rows));
}
}
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL (http://user:pass@host:port)
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")!;
var proxy = Environment.GetEnvironmentVariable("PROXY_URL")!;
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
using var playwright = await Playwright.CreateAsync();
// One browser per engine family, and the Playwright client that speaks to it.
var browsers = new (string Name, IBrowserType Family)[]
{
("chrome", playwright.Chromium), ("edge", playwright.Chromium), ("firefox", playwright.Firefox),
("camoufox", playwright.Firefox), ("webkit", playwright.Webkit),
};
// A residential exit occasionally times out: one retry, in a fresh tab.
async Task<JsonNode> Fingerprint(IBrowser browser)
{
for (var attempt = 1; ; attempt++)
{
try
{
var page = await browser.NewPageAsync();
return JsonNode.Parse(await (await page.GotoAsync("https://tls.peet.ws/api/all", new() { Timeout = 30000 }))!.TextAsync())!;
}
catch (Exception err) when ((err is PlaywrightException or TimeoutException) && attempt < 2) { }
}
}
var rows = new JsonArray();
foreach (var (name, family) in browsers)
{
var res = await http.PostAsJsonAsync($"https://starter.cdpfleet.com/{name}/session", new { proxy, headless = true });
if (!res.IsSuccessStatusCode) throw new Exception($"launch {name}: {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");
var wsUrl = (await res.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("wsUrl").GetString()!;
var browser = await family.ConnectAsync(wsUrl, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
try
{
// Navigate the browser itself: the JSON describes the TLS ClientHello and HTTP/2
// frames this very browser sent (APIRequest would use Playwright's own client).
var fp = await Fingerprint(browser);
var tls = fp["tls"]!;
var h2 = fp["http2"] as JsonObject;
rows.Add(new JsonObject
{
["browser"] = name,
["version"] = browser.Version,
["user_agent"] = fp["user_agent"]?.DeepClone(),
["http_version"] = fp["http_version"]?.DeepClone(),
["ja4"] = tls["ja4"]?.DeepClone(),
["ja3_hash"] = tls["ja3_hash"]?.DeepClone(),
["peetprint_hash"] = tls["peetprint_hash"]?.DeepClone(),
["akamai_h2"] = h2?["akamai_fingerprint"]?.DeepClone(),
["akamai_h2_hash"] = h2?["akamai_fingerprint_hash"]?.DeepClone(),
["cipher_suites"] = tls["ciphers"]!.AsArray().Count,
["extensions"] = tls["extensions"]!.AsArray().Count,
});
}
finally
{
await browser.CloseAsync(); // ends the session and stops billing
}
}
Console.WriteLine(rows.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
// 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 (http://user:pass@host:port)
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"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 {
Browser string `json:"browser"`
Version string `json:"version"`
UserAgent string `json:"user_agent"`
HTTPVersion string `json:"http_version"`
JA4 string `json:"ja4"`
JA3Hash string `json:"ja3_hash"`
PeetprintHash string `json:"peetprint_hash"`
AkamaiH2 *string `json:"akamai_h2"`
AkamaiH2Hash *string `json:"akamai_h2_hash"`
CipherSuites int `json:"cipher_suites"`
Extensions int `json:"extensions"`
}
// The part of tls.peet.ws/api/all we use.
type peet struct {
UserAgent string `json:"user_agent"`
HTTPVersion string `json:"http_version"`
TLS struct {
JA4 string `json:"ja4"`
JA3Hash string `json:"ja3_hash"`
PeetprintHash string `json:"peetprint_hash"`
Ciphers []any `json:"ciphers"`
Extensions []any `json:"extensions"`
} `json:"tls"`
HTTP2 *struct {
AkamaiFingerprint string `json:"akamai_fingerprint"`
AkamaiFingerprintHash string `json:"akamai_fingerprint_hash"`
} `json:"http2"`
}
// Navigate the browser itself: the JSON describes the TLS ClientHello and HTTP/2
// frames this very browser sent (APIRequest would use Playwright's own client).
// A residential exit occasionally times out: one retry, in a fresh tab.
func fingerprint(browser playwright.Browser) (fp peet, err error) {
for attempt := 1; attempt <= 2; attempt++ {
page, _ := browser.NewPage()
var res playwright.Response
if res, err = page.Goto("https://tls.peet.ws/api/all", playwright.PageGotoOptions{Timeout: playwright.Float(30000)}); err == nil {
return fp, res.JSON(&fp)
}
}
return fp, err
}
func main() {
pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
if err != nil {
log.Fatal(err)
}
defer pw.Stop()
// One browser per engine family, and the Playwright client that speaks to it.
browsers := []struct {
name string
family playwright.BrowserType
}{{"chrome", pw.Chromium}, {"edge", pw.Chromium}, {"firefox", pw.Firefox}, {"camoufox", pw.Firefox}, {"webkit", pw.WebKit}}
rows := []row{}
for _, b := range browsers {
session, err := launch(b.name, map[string]any{"proxy": os.Getenv("PROXY_URL"), "headless": true})
if err != nil {
log.Fatal(err)
}
browser, err := b.family.Connect(session["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
if err != nil {
log.Fatal(err)
}
fp, err := fingerprint(browser)
if err != nil {
log.Fatal(err)
}
r := row{Browser: b.name, Version: browser.Version(), UserAgent: fp.UserAgent, HTTPVersion: fp.HTTPVersion,
JA4: fp.TLS.JA4, JA3Hash: fp.TLS.JA3Hash, PeetprintHash: fp.TLS.PeetprintHash,
CipherSuites: len(fp.TLS.Ciphers), Extensions: len(fp.TLS.Extensions)}
if fp.HTTP2 != nil {
r.AkamaiH2, r.AkamaiH2Hash = &fp.HTTP2.AkamaiFingerprint, &fp.HTTP2.AkamaiFingerprintHash
}
rows = append(rows, r)
browser.Close() // ends the session and stops billing
}
out, _ := json.MarshalIndent(rows, "", " ")
fmt.Println(string(out))
}
What we got
| Browser | Version | HTTP | JA4 | Akamai h2 hash | Ciphers | Extensions |
|---|---|---|---|---|---|---|
| chrome | 153.0.8010.36 | h2 | t13d1517h2_8daaf6152771_cb7bf5808d99 | 52d84b11737d980aef856699f885ca86 | 16 | 19 |
| edge | 152.0.4191.66 | h2 | t13d1516h2_8daaf6152771_806a8c22fdea | 52d84b11737d980aef856699f885ca86 | 16 | 18 |
| firefox | 150.0.2 | h2 | t13d1617h2_86a278354501_3cbfd9057e0d | 6ea73faa8fc5aac76bded7bd238f6433 | 16 | 17 |
| camoufox | 152.0.4-beta.30 | h2 | t13d1617h2_86a278354501_3cbfd9057e0d | 6ea73faa8fc5aac76bded7bd238f6433 | 16 | 17 |
| webkit | 26.4 | HTTP/1.1 | t13d2913h1_723694b0fccc_5671b5df5029 | — | 29 | 13 |
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)
[
{
"browser": "chrome",
"version": "153.0.8010.36",
"user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/203.0.113.1 Safari/537.36",
"http_version": "h2",
"ja4": "t13d1517h2_8daaf6152771_cb7bf5808d99",
"ja3_hash": "1dadc3d8371c7cd23b73baca6ce4a14e",
"peetprint_hash": "fc97c1cdfb1409c9a9326c1b726d1dee",
"akamai_h2": "1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p",
"akamai_h2_hash": "52d84b11737d980aef856699f885ca86",
"cipher_suites": 16,
"extensions": 19
},
{
"browser": "edge",
"version": "152.0.4191.66",
"user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/203.0.113.2 Safari/537.36 Edg/203.0.113.2",
"http_version": "h2",
"ja4": "t13d1516h2_8daaf6152771_806a8c22fdea",
"ja3_hash": "8aed6551da98b7685768242b17d63530",
"peetprint_hash": "8f568b2a409b82d0ec24cb84bf3bc3fc",
"akamai_h2": "1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p",
"akamai_h2_hash": "52d84b11737d980aef856699f885ca86",
"cipher_suites": 16,
"extensions": 18
},
{
"browser": "firefox",
"version": "150.0.2",
"user_agent": "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0",
"http_version": "h2",
"ja4": "t13d1617h2_86a278354501_3cbfd9057e0d",
"ja3_hash": "6447ab086255d194909d4013b1a89e87",
"peetprint_hash": "fd4547eeb41f073156b7bc8125a79a3c",
"akamai_h2": "1:65536;2:0;4:131072;5:16384|12517377|0|m,p,a,s",
"akamai_h2_hash": "6ea73faa8fc5aac76bded7bd238f6433",
"cipher_suites": 16,
"extensions": 17
},
{
"browser": "camoufox",
"version": "152.0.4-beta.30",
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0",
"http_version": "h2",
"ja4": "t13d1617h2_86a278354501_3cbfd9057e0d",
"ja3_hash": "6447ab086255d194909d4013b1a89e87",
"peetprint_hash": "fd4547eeb41f073156b7bc8125a79a3c",
"akamai_h2": "1:65536;2:0;4:131072;5:16384|12517377|0|m,p,a,s",
"akamai_h2_hash": "6ea73faa8fc5aac76bded7bd238f6433",
"cipher_suites": 16,
"extensions": 17
},
{
"browser": "webkit",
"version": "26.4",
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15",
"http_version": "HTTP/1.1",
"ja4": "t13d2913h1_723694b0fccc_5671b5df5029",
"ja3_hash": "2cbcec37dfff29e4c6a34dd35ebe1d70",
"peetprint_hash": "e2a4f6ec1c75767498bc87d934bfccec",
"akamai_h2": null,
"akamai_h2_hash": null,
"cipher_suites": 29,
"extensions": 13
}
]Takeaways
- Each engine sends its own real fingerprint. Chrome's JA4 and HTTP/2 settings are Chrome's; Firefox's are Firefox's. Nothing to patch.
- Chrome and Edge differ by one extension (JA4
…1517…vs…1516…) but share the HTTP/2 fingerprint — both are Chromium network stacks. - Camoufox picks a random OS for its user agent in every session (set
osto choose one) — and whatever it claims, its TLS is identical to Linux Firefox. That's fine: Firefox's TLS stack (NSS) is the same on every OS, so a real Windows or Mac Firefox sends exactly this. - WebKit speaks HTTP/1.1 through a proxy, with 29 cipher suites — nothing like Safari on a Mac (HTTP/2, fewer ciphers). Use WebKit for rendering tests, not to impersonate Safari.
- Fingerprints are stable per build: read them once per version, not per request.