[Examples & benchmarks](https://cdpfleet.com/docs/cases) / #83 · 2026-11-05 · Benchmark · Medium · for [web scraping](https://cdpfleet.com/web-scraping), [testing](https://cdpfleet.com/cross-browser-testing)

# Brave's fingerprint randomisation per site, per context and per launch, and what public fingerprint pages make of it

Brave adds noise to canvas, audio, cores, memory and the plugin list, keyed to the site and the session. One probe on two made-up sites, the same site again, a second context and a second launch shows exactly when the values change, next to headful Chrome. Then CreepJS, browserleaks, sannysoft, deviceandbrowserinfo and the fingerprint.com playground give their own verdicts on Brave headless, Brave headful and Chrome. Dated 2026-11-05.

[Brave](https://cdpfleet.com/docs/browsers/brave) [Google Chrome](https://cdpfleet.com/docs/browsers/chrome)

Run on production on 2026-11-05: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go

A benchmark: the results below are what this setup measured on the dates shown, on our own test runs. Sites, networks and browsers change, so rerun the code before you rely on the numbers.

## The problem

Brave doesn't hide its fingerprint, it randomises it ("farbling"): canvas and audio output get a little noise, `hardwareConcurrency` and `deviceMemory` are made up, and the plugin list gets invented names. [Case 66](https://cdpfleet.com/docs/cases/fingerprint-stability-across-launches) found Brave's canvas and audio new on every launch, on one site. But the noise is said to be keyed to the site as well, so the useful questions are finer: do two sites in the same session see different values, does the same site see the same ones when you come back, what does a second browser context get, and what do public fingerprint pages conclude when they meet a browser that randomises? This case measures that on a dated day, on public test pages and on two sites served by the program itself.

## What we used, and why

| What | Why |
| --- | --- |
| Three setups | `brave` with `headless: true`, `brave` with `headless: false`, and `chrome` with `headless: false` as the reference. Brave runs with its default Shields; nothing is configured. Every launch uses `record: false` and the same proxy. |
| Two made-up sites via `context.route()` | `https://site-a.example/` and `https://site-b.test/` are two different registrable domains, so the browser treats them as two first-party sites; `route.fulfill()` serves a one-line page for both, and no request leaves the browser. |
| A small fingerprinting probe | The same script in every language reads a canvas hash (drawn twice, to check the page agrees with itself), an `OfflineAudioContext` hash, `navigator.hardwareConcurrency`, `navigator.deviceMemory`, a hash of the `navigator.plugins` names, `screen.width × height` and `outerWidth × outerHeight`. |
| Six visits per setup, each in a new tab | Launch 1: site A, site B, site A again, then site A in a second `browser.newContext()`. Launch 2 (a new session): site A and site B. |
| Five public test pages, one load each per setup | CreepJS (its headless and stealth scores, the "privacy" it names, its canvas noise estimate and the sections it marks as lies), browserleaks.com/canvas (signature and uniqueness), bot.sannysoft.com, deviceandbrowserinfo.com and the fingerprint.com playground (bot verdict and suspect score only), read the way [case 63](https://cdpfleet.com/docs/cases/what-fingerprint-test-pages-see) reads them. |

## How it works

1.  For each of the three setups, launch a session and add routes for the two made-up sites to a new context.
2.  Visit site A, site B and site A again, each in a new tab, running the probe; then site A in a second context of the same session.
3.  Close the session, launch a new one, and visit site A and site B again.
4.  In a fresh context of that second session, load each public test page once, wait for its verdict and read it from the page.
5.  Print the probe values of all six visits and the five verdicts per setup.

## The code

The same program in five languages (also [on GitHub](https://github.com/cdpfleet/cdpfleet/tree/main/cases/83-brave-farbling), 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:pass@proxy.example.com:8000`

 

```node
// npm install playwright@1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL (an http(s) or socks5 proxy; one load per public test page per setup)
import { chromium } from 'playwright';

const KEY = process.env.CDPFLEET_API_KEY;
const PROXY = process.env.PROXY_URL;

const SETUPS = [
  { setup: 'Brave, headless', engine: 'brave', headless: true },
  { setup: 'Brave, headful', engine: 'brave', headless: false },
  { setup: 'Google Chrome, headful', engine: 'chrome', headless: false },
];

// Two made-up sites on different registrable domains, served by page routing: the browser
// treats them as two first-party sites, and no request leaves the browser.
const SITES = { A: 'https://site-a.example/', B: 'https://site-b.test/' };
const HTML = '<!doctype html><title>lab 83</title><p>A page with nothing on it but this probe.</p>';

// What a fingerprinting script reads, the same in every language: a canvas hash (drawn twice),
// an OfflineAudioContext hash, cores, memory, the plugin list, the screen and the window size.
const PROBE = `(async () => {
  const fnv = (s) => { let h = 0x811c9dc5; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; } return h.toString(16).padStart(8, '0'); };
  const draw = () => {
    const c = document.createElement('canvas'); c.width = 240; c.height = 60;
    const x = c.getContext('2d'); x.textBaseline = 'top'; x.font = '16px Arial';
    x.fillStyle = '#f60'; x.fillRect(100, 1, 62, 20);
    x.fillStyle = '#069'; x.fillText('cdpfleet fp 83 <@>', 2, 15);
    x.fillStyle = 'rgba(102, 204, 0, 0.7)'; x.fillText('cdpfleet fp 83 <@>', 4, 17);
    return c.toDataURL();
  };
  const audio = async () => {
    try {
      const ctx = new OfflineAudioContext(1, 44100, 44100);
      const o = ctx.createOscillator(); o.type = 'triangle'; o.frequency.value = 10000;
      const k = ctx.createDynamicsCompressor();
      k.threshold.value = -50; k.knee.value = 40; k.ratio.value = 12; k.attack.value = 0; k.release.value = 0.25;
      o.connect(k); k.connect(ctx.destination); o.start(0);
      const d = new Float32Array(500); (await ctx.startRendering()).copyFromChannel(d, 0, 4500);
      let s = ''; for (let i = 0; i < 500; i++) s += d[i].toFixed(10) + ',';
      return fnv(s);
    } catch (e) { return 'error: ' + e.name; }
  };
  const c1 = draw(), c2 = draw();
  const plugins = Array.from(navigator.plugins).map((p) => p.name);
  return {
    site: location.hostname,
    canvas: fnv(c1), canvas_same_in_page: c1 === c2,
    audio: await audio(),
    cores: navigator.hardwareConcurrency, memory: navigator.deviceMemory ?? null,
    plugins: fnv(plugins.join(',')), plugin_names: plugins,
    screen: screen.width + 'x' + screen.height, window: outerWidth + 'x' + outerHeight,
  };
})()`;

// Each public test page's own verdict, read from its DOM; "ready" is polled until the page has
// finished its checks. The same expressions run in every language.
const PAGES = [
  {
    key: 'creepjs', url: 'https://abrahamjuliot.github.io/creepjs/',
    ready: "/\\d+% stealth/.test(document.body.innerText) && /privacy: \\S/.test(document.body.innerText)",
    // A section CreepJS caught lying gets its hash marked with the "lies" class.
    read: "(() => { const t = document.body.innerText.replace(/\\s+/g, ' '); const m = /(\\d+)% like headless[\\s\\S]*?(\\d+)% headless:[\\s\\S]*?(\\d+)% stealth/.exec(t); const privacy = /privacy: (\\S+)/.exec(t); const noise = /rendering: (\\d+% \\w+ noise)/.exec(t); return { like_headless_pct: m ? +m[1] : null, headless_pct: m ? +m[2] : null, stealth_pct: m ? +m[3] : null, privacy: privacy ? privacy[1] : null, canvas_noise: noise ? noise[1] : null, lies: Array.from(document.querySelectorAll('.lies')).map((e) => (e.previousElementSibling ? e.previousElementSibling.innerText.trim() : '?')) }; })()",
  },
  {
    key: 'browserleaks', url: 'https://browserleaks.com/canvas',
    ready: "/Uniqueness\\s+\\d/.test(document.body.innerText)",
    read: "(() => { const t = document.body.innerText; const sig = /Signature\\s+([0-9A-Fa-f]{32})/.exec(t); const u = /Uniqueness\\s+([^\\n]+)/.exec(t); return { signature: sig ? sig[1] : null, uniqueness: u ? u[1].trim() : null }; })()",
  },
  {
    key: 'sannysoft', url: 'https://bot.sannysoft.com/',
    ready: "document.querySelectorAll('td.passed, td.failed').length > 10",
    read: "(() => { const rows = Array.from(document.querySelectorAll('tr')).filter((tr) => tr.querySelector('td.passed, td.failed, td.warn')); const failed = rows.filter((tr) => tr.querySelector('td.failed')).map((tr) => tr.cells[0].innerText.replace(/\\s+/g, ' ').trim()); return { checks: rows.length, failed: failed.length, failed_names: failed }; })()",
  },
  {
    key: 'deviceandbrowserinfo', url: 'https://deviceandbrowserinfo.com/are_you_a_bot',
    ready: "document.body.innerText.indexOf('\"isBot\"') >= 0",
    read: "(() => { const t = document.body.innerText; const s = t.indexOf('{', t.indexOf('\"isBot\"') - 10); let depth = 0, e = s; for (; e < t.length; e++) { if (t[e] === '{') depth++; if (t[e] === '}' && --depth === 0) break; } try { const j = JSON.parse(t.slice(s, e + 1)); return { is_bot: j.isBot, signals: Object.keys(j.details || {}).filter((k) => j.details[k] === true) }; } catch (err) { return { is_bot: null, signals: [] }; } })()",
  },
  {
    key: 'fingerprint', url: 'https://demo.fingerprint.com/playground',
    ready: "/Suspect Score \\d+/.test(document.body.innerText.replace(/\\s+/g, ' '))",
    // Only the bot verdict and the suspect score are read (the page also names proxy networks).
    read: "(() => { const t = document.body.innerText.replace(/\\s+/g, ' '); const bot = /\\bBot (.+?) VPN /.exec(t); const score = /Suspect Score (\\d+)/.exec(t); return { bot: bot ? bot[1].replace(/[^\\x20-\\x7e]/g, '').replace(/\\s+/g, ' ').trim() : null, suspect_score: score ? +score[1] : null }; })()",
  },
];

// 503 means the fleet is momentarily busy: wait Retry-After and try again.
async function launch(engine, headless) {
  for (let attempt = 1; ; attempt++) {
    const res = await fetch(`https://starter.cdpfleet.com/${engine}/session`, {
      method: 'POST',
      headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
      body: JSON.stringify({ proxy: PROXY, record: false, headless }),
    });
    if (res.ok) return res.json();
    if (![429, 503].includes(res.status) || attempt === 5) throw new Error(`launch ${res.status} ${await res.text()}`);
    await new Promise((r) => setTimeout(r, (Number(res.headers.get('retry-after')) || 5) * 1000));
  }
}

async function siteContext(browser) {
  const context = await browser.newContext();
  for (const url of Object.values(SITES)) await context.route(`${url}**`, (route) => route.fulfill({ contentType: 'text/html', body: HTML }));
  return context;
}

// One visit = a new tab in the given context, the probe, the tab closed.
async function visit(context, label, launchNo, contextNo, site) {
  const page = await context.newPage();
  try {
    await page.goto(SITES[site]);
    return { visit: label, launch: launchNo, context: contextNo, ...(await page.evaluate(PROBE)) };
  } finally {
    await page.close();
  }
}

const out = { setups: [] };
for (const s of SETUPS) {
  const row = { setup: s.setup, engine: s.engine, headless: s.headless, visits: [], pages: {} };
  // Launch 1: site A, site B, site A again in a new tab, then site A in a second context.
  let session = await launch(s.engine, s.headless);
  let browser = await chromium.connect(session.wsUrl, { headers: { 'x-api-key': KEY } });
  try {
    const one = await siteContext(browser);
    row.visits.push(await visit(one, 'site A', 1, 1, 'A'));
    row.visits.push(await visit(one, 'site B', 1, 1, 'B'));
    row.visits.push(await visit(one, 'site A again', 1, 1, 'A'));
    const two = await siteContext(browser);
    row.visits.push(await visit(two, 'site A, second context', 1, 2, 'A'));
  } finally {
    await browser.close();
  }
  // Launch 2: sites A and B again, then each public test page once, in a fresh context.
  session = await launch(s.engine, s.headless);
  browser = await chromium.connect(session.wsUrl, { headers: { 'x-api-key': KEY } });
  try {
    const three = await siteContext(browser);
    row.visits.push(await visit(three, 'site A, next launch', 2, 1, 'A'));
    row.visits.push(await visit(three, 'site B, next launch', 2, 1, 'B'));
    const page = await (await browser.newContext()).newPage();
    for (const p of PAGES) {
      try {
        // A proxy tunnel drop is tried once more; a page that loaded is never reloaded.
        try { await page.goto(p.url, { timeout: 60000 }); } catch { await page.goto(p.url, { timeout: 60000 }); }
        await page.waitForFunction(p.ready, null, { timeout: 45000 });
        row.pages[p.key] = await page.evaluate(p.read);
      } catch (err) {
        row.pages[p.key] = { error: err.message.split('\n')[0].slice(0, 120) };
      }
    }
  } finally {
    await browser.close();
  }
  out.setups.push(row);
}
console.log(JSON.stringify(out, null, 2));
```

```python
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL (an http(s) or socks5 proxy; one load per public test page per setup)
import json
import os
import time

import requests
from playwright.sync_api import sync_playwright

KEY = os.environ["CDPFLEET_API_KEY"]
PROXY = os.environ["PROXY_URL"]

SETUPS = [
    {"setup": "Brave, headless", "engine": "brave", "headless": True},
    {"setup": "Brave, headful", "engine": "brave", "headless": False},
    {"setup": "Google Chrome, headful", "engine": "chrome", "headless": False},
]

# Two made-up sites on different registrable domains, served by page routing: the browser
# treats them as two first-party sites, and no request leaves the browser.
SITES = {"A": "https://site-a.example/", "B": "https://site-b.test/"}
HTML = "<!doctype html><title>lab 83</title><p>A page with nothing on it but this probe.</p>"

# What a fingerprinting script reads, the same in every language: a canvas hash (drawn twice),
# an OfflineAudioContext hash, cores, memory, the plugin list, the screen and the window size.
PROBE = r"""(async () => {
  const fnv = (s) => { let h = 0x811c9dc5; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; } return h.toString(16).padStart(8, '0'); };
  const draw = () => {
    const c = document.createElement('canvas'); c.width = 240; c.height = 60;
    const x = c.getContext('2d'); x.textBaseline = 'top'; x.font = '16px Arial';
    x.fillStyle = '#f60'; x.fillRect(100, 1, 62, 20);
    x.fillStyle = '#069'; x.fillText('cdpfleet fp 83 <@>', 2, 15);
    x.fillStyle = 'rgba(102, 204, 0, 0.7)'; x.fillText('cdpfleet fp 83 <@>', 4, 17);
    return c.toDataURL();
  };
  const audio = async () => {
    try {
      const ctx = new OfflineAudioContext(1, 44100, 44100);
      const o = ctx.createOscillator(); o.type = 'triangle'; o.frequency.value = 10000;
      const k = ctx.createDynamicsCompressor();
      k.threshold.value = -50; k.knee.value = 40; k.ratio.value = 12; k.attack.value = 0; k.release.value = 0.25;
      o.connect(k); k.connect(ctx.destination); o.start(0);
      const d = new Float32Array(500); (await ctx.startRendering()).copyFromChannel(d, 0, 4500);
      let s = ''; for (let i = 0; i < 500; i++) s += d[i].toFixed(10) + ',';
      return fnv(s);
    } catch (e) { return 'error: ' + e.name; }
  };
  const c1 = draw(), c2 = draw();
  const plugins = Array.from(navigator.plugins).map((p) => p.name);
  return {
    site: location.hostname,
    canvas: fnv(c1), canvas_same_in_page: c1 === c2,
    audio: await audio(),
    cores: navigator.hardwareConcurrency, memory: navigator.deviceMemory ?? null,
    plugins: fnv(plugins.join(',')), plugin_names: plugins,
    screen: screen.width + 'x' + screen.height, window: outerWidth + 'x' + outerHeight,
  };
})()"""

# Each public test page's own verdict, read from its DOM; "ready" is polled until the page has
# finished its checks. The same expressions run in every language.
PAGES = [
    {
        "key": "creepjs", "url": "https://abrahamjuliot.github.io/creepjs/",
        "ready": r"""/\d+% stealth/.test(document.body.innerText) && /privacy: \S/.test(document.body.innerText)""",
        # A section CreepJS caught lying gets its hash marked with the "lies" class.
        "read": r"""(() => { const t = document.body.innerText.replace(/\s+/g, ' '); const m = /(\d+)% like headless[\s\S]*?(\d+)% headless:[\s\S]*?(\d+)% stealth/.exec(t); const privacy = /privacy: (\S+)/.exec(t); const noise = /rendering: (\d+% \w+ noise)/.exec(t); return { like_headless_pct: m ? +m[1] : null, headless_pct: m ? +m[2] : null, stealth_pct: m ? +m[3] : null, privacy: privacy ? privacy[1] : null, canvas_noise: noise ? noise[1] : null, lies: Array.from(document.querySelectorAll('.lies')).map((e) => (e.previousElementSibling ? e.previousElementSibling.innerText.trim() : '?')) }; })()""",
    },
    {
        "key": "browserleaks", "url": "https://browserleaks.com/canvas",
        "ready": r"""/Uniqueness\s+\d/.test(document.body.innerText)""",
        "read": r"""(() => { const t = document.body.innerText; const sig = /Signature\s+([0-9A-Fa-f]{32})/.exec(t); const u = /Uniqueness\s+([^\n]+)/.exec(t); return { signature: sig ? sig[1] : null, uniqueness: u ? u[1].trim() : null }; })()""",
    },
    {
        "key": "sannysoft", "url": "https://bot.sannysoft.com/",
        "ready": r"""document.querySelectorAll('td.passed, td.failed').length > 10""",
        "read": r"""(() => { const rows = Array.from(document.querySelectorAll('tr')).filter((tr) => tr.querySelector('td.passed, td.failed, td.warn')); const failed = rows.filter((tr) => tr.querySelector('td.failed')).map((tr) => tr.cells[0].innerText.replace(/\s+/g, ' ').trim()); return { checks: rows.length, failed: failed.length, failed_names: failed }; })()""",
    },
    {
        "key": "deviceandbrowserinfo", "url": "https://deviceandbrowserinfo.com/are_you_a_bot",
        "ready": r"""document.body.innerText.indexOf('"isBot"') >= 0""",
        "read": r"""(() => { const t = document.body.innerText; const s = t.indexOf('{', t.indexOf('"isBot"') - 10); let depth = 0, e = s; for (; e < t.length; e++) { if (t[e] === '{') depth++; if (t[e] === '}' && --depth === 0) break; } try { const j = JSON.parse(t.slice(s, e + 1)); return { is_bot: j.isBot, signals: Object.keys(j.details || {}).filter((k) => j.details[k] === true) }; } catch (err) { return { is_bot: null, signals: [] }; } })()""",
    },
    {
        "key": "fingerprint", "url": "https://demo.fingerprint.com/playground",
        "ready": r"""/Suspect Score \d+/.test(document.body.innerText.replace(/\s+/g, ' '))""",
        # Only the bot verdict and the suspect score are read (the page also names proxy networks).
        "read": r"""(() => { const t = document.body.innerText.replace(/\s+/g, ' '); const bot = /\bBot (.+?) VPN /.exec(t); const score = /Suspect Score (\d+)/.exec(t); return { bot: bot ? bot[1].replace(/[^\x20-\x7e]/g, '').replace(/\s+/g, ' ').trim() : null, suspect_score: score ? +score[1] : null }; })()""",
    },
]

# 503 means the fleet is momentarily busy: wait Retry-After and try again.
def launch(engine, headless):
    for attempt in range(1, 6):
        res = requests.post(f"https://starter.cdpfleet.com/{engine}/session", headers={"x-api-key": KEY},
                            json={"proxy": PROXY, "record": False, "headless": headless}, timeout=120)
        if res.ok:
            return res.json()
        if res.status_code not in (429, 503) or attempt == 5:
            raise RuntimeError(f"launch {res.status_code} {res.text}")
        time.sleep(int(res.headers.get("retry-after") or 5))

def site_context(browser):
    context = browser.new_context()
    for url in SITES.values():
        context.route(url + "**", lambda route: route.fulfill(content_type="text/html", body=HTML))
    return context

# One visit = a new tab in the given context, the probe, the tab closed.
def visit(context, label, launch_no, context_no, site):
    page = context.new_page()
    try:
        page.goto(SITES[site])
        return {"visit": label, "launch": launch_no, "context": context_no, **page.evaluate(PROBE)}
    finally:
        page.close()

out = {"setups": []}
with sync_playwright() as p:
    for s in SETUPS:
        row = {"setup": s["setup"], "engine": s["engine"], "headless": s["headless"], "visits": [], "pages": {}}
        # Launch 1: site A, site B, site A again in a new tab, then site A in a second context.
        session = launch(s["engine"], s["headless"])
        browser = p.chromium.connect(session["wsUrl"], headers={"x-api-key": KEY})
        try:
            one = site_context(browser)
            row["visits"].append(visit(one, "site A", 1, 1, "A"))
            row["visits"].append(visit(one, "site B", 1, 1, "B"))
            row["visits"].append(visit(one, "site A again", 1, 1, "A"))
            two = site_context(browser)
            row["visits"].append(visit(two, "site A, second context", 1, 2, "A"))
        finally:
            browser.close()
        # Launch 2: sites A and B again, then each public test page once, in a fresh context.
        session = launch(s["engine"], s["headless"])
        browser = p.chromium.connect(session["wsUrl"], headers={"x-api-key": KEY})
        try:
            three = site_context(browser)
            row["visits"].append(visit(three, "site A, next launch", 2, 1, "A"))
            row["visits"].append(visit(three, "site B, next launch", 2, 1, "B"))
            page = browser.new_context().new_page()
            for pg in PAGES:
                try:
                    # A proxy tunnel drop is tried once more; a page that loaded is never reloaded.
                    try:
                        page.goto(pg["url"], timeout=60000)
                    except Exception:
                        page.goto(pg["url"], timeout=60000)
                    page.wait_for_function(pg["ready"], timeout=45000)
                    row["pages"][pg["key"]] = page.evaluate(pg["read"])
                except Exception as err:
                    row["pages"][pg["key"]] = {"error": str(err).split("\n")[0][:120]}
        finally:
            browser.close()
        out["setups"].append(row)
print(json.dumps(out, indent=2))
```

```java
// 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 (an http(s) or socks5 proxy; one load per public test page per setup)
import com.google.gson.*;
import com.microsoft.playwright.*;
import java.net.URI;
import java.net.http.*;
import java.util.*;

public class Main {
  static final String KEY = System.getenv("CDPFLEET_API_KEY");
  static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().serializeNulls().create();
  static final HttpClient HTTP = HttpClient.newHttpClient();

  record Setup(String name, String engine, boolean headless) {}

  static final List<Setup> SETUPS = List.of(
      new Setup("Brave, headless", "brave", true),
      new Setup("Brave, headful", "brave", false),
      new Setup("Google Chrome, headful", "chrome", false));

  // Two made-up sites on different registrable domains, served by page routing: the browser
  // treats them as two first-party sites, and no request leaves the browser.
  static final Map<String, String> SITES = Map.of("A", "https://site-a.example/", "B", "https://site-b.test/");
  static final String HTML = "<!doctype html><title>lab 83</title><p>A page with nothing on it but this probe.</p>";

  // What a fingerprinting script reads, the same in every language: a canvas hash (drawn twice),
  // an OfflineAudioContext hash, cores, memory, the plugin list, the screen and the window size.
  static final String PROBE = """
      (async () => {
        const fnv = (s) => { let h = 0x811c9dc5; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; } return h.toString(16).padStart(8, '0'); };
        const draw = () => {
          const c = document.createElement('canvas'); c.width = 240; c.height = 60;
          const x = c.getContext('2d'); x.textBaseline = 'top'; x.font = '16px Arial';
          x.fillStyle = '#f60'; x.fillRect(100, 1, 62, 20);
          x.fillStyle = '#069'; x.fillText('cdpfleet fp 83 <@>', 2, 15);
          x.fillStyle = 'rgba(102, 204, 0, 0.7)'; x.fillText('cdpfleet fp 83 <@>', 4, 17);
          return c.toDataURL();
        };
        const audio = async () => {
          try {
            const ctx = new OfflineAudioContext(1, 44100, 44100);
            const o = ctx.createOscillator(); o.type = 'triangle'; o.frequency.value = 10000;
            const k = ctx.createDynamicsCompressor();
            k.threshold.value = -50; k.knee.value = 40; k.ratio.value = 12; k.attack.value = 0; k.release.value = 0.25;
            o.connect(k); k.connect(ctx.destination); o.start(0);
            const d = new Float32Array(500); (await ctx.startRendering()).copyFromChannel(d, 0, 4500);
            let s = ''; for (let i = 0; i < 500; i++) s += d[i].toFixed(10) + ',';
            return fnv(s);
          } catch (e) { return 'error: ' + e.name; }
        };
        const c1 = draw(), c2 = draw();
        const plugins = Array.from(navigator.plugins).map((p) => p.name);
        return {
          site: location.hostname,
          canvas: fnv(c1), canvas_same_in_page: c1 === c2,
          audio: await audio(),
          cores: navigator.hardwareConcurrency, memory: navigator.deviceMemory ?? null,
          plugins: fnv(plugins.join(',')), plugin_names: plugins,
          screen: screen.width + 'x' + screen.height, window: outerWidth + 'x' + outerHeight,
        };
      })()
      """;

  record TestPage(String key, String url, String ready, String read) {}

  // Each public test page's own verdict, read from its DOM; ready is polled until the page has
  // finished its checks. The same expressions run in every language.
  static final List<TestPage> PAGES = List.of(
      // A section CreepJS caught lying gets its hash marked with the "lies" class.
      new TestPage("creepjs", "https://abrahamjuliot.github.io/creepjs/",
          "/\\d+% stealth/.test(document.body.innerText) && /privacy: \\S/.test(document.body.innerText)",
          "(() => { const t = document.body.innerText.replace(/\\s+/g, ' '); const m = /(\\d+)% like headless[\\s\\S]*?(\\d+)% headless:[\\s\\S]*?(\\d+)% stealth/.exec(t); const privacy = /privacy: (\\S+)/.exec(t); const noise = /rendering: (\\d+% \\w+ noise)/.exec(t); return { like_headless_pct: m ? +m[1] : null, headless_pct: m ? +m[2] : null, stealth_pct: m ? +m[3] : null, privacy: privacy ? privacy[1] : null, canvas_noise: noise ? noise[1] : null, lies: Array.from(document.querySelectorAll('.lies')).map((e) => (e.previousElementSibling ? e.previousElementSibling.innerText.trim() : '?')) }; })()"),
      new TestPage("browserleaks", "https://browserleaks.com/canvas",
          "/Uniqueness\\s+\\d/.test(document.body.innerText)",
          "(() => { const t = document.body.innerText; const sig = /Signature\\s+([0-9A-Fa-f]{32})/.exec(t); const u = /Uniqueness\\s+([^\\n]+)/.exec(t); return { signature: sig ? sig[1] : null, uniqueness: u ? u[1].trim() : null }; })()"),
      new TestPage("sannysoft", "https://bot.sannysoft.com/",
          "document.querySelectorAll('td.passed, td.failed').length > 10",
          "(() => { const rows = Array.from(document.querySelectorAll('tr')).filter((tr) => tr.querySelector('td.passed, td.failed, td.warn')); const failed = rows.filter((tr) => tr.querySelector('td.failed')).map((tr) => tr.cells[0].innerText.replace(/\\s+/g, ' ').trim()); return { checks: rows.length, failed: failed.length, failed_names: failed }; })()"),
      new TestPage("deviceandbrowserinfo", "https://deviceandbrowserinfo.com/are_you_a_bot",
          "document.body.innerText.indexOf('\"isBot\"') >= 0",
          "(() => { const t = document.body.innerText; const s = t.indexOf('{', t.indexOf('\"isBot\"') - 10); let depth = 0, e = s; for (; e < t.length; e++) { if (t[e] === '{') depth++; if (t[e] === '}' && --depth === 0) break; } try { const j = JSON.parse(t.slice(s, e + 1)); return { is_bot: j.isBot, signals: Object.keys(j.details || {}).filter((k) => j.details[k] === true) }; } catch (err) { return { is_bot: null, signals: [] }; } })()"),
      // Only the bot verdict and the suspect score are read (the page also names proxy networks).
      new TestPage("fingerprint", "https://demo.fingerprint.com/playground",
          "/Suspect Score \\d+/.test(document.body.innerText.replace(/\\s+/g, ' '))",
          "(() => { const t = document.body.innerText.replace(/\\s+/g, ' '); const bot = /\\bBot (.+?) VPN /.exec(t); const score = /Suspect Score (\\d+)/.exec(t); return { bot: bot ? bot[1].replace(/[^\\x20-\\x7e]/g, '').replace(/\\s+/g, ' ').trim() : null, suspect_score: score ? +score[1] : null }; })()"));

  // 503 means the fleet is momentarily busy: wait Retry-After and try again.
  static String launch(String engine, boolean headless) throws Exception {
    JsonObject body = new JsonObject();
    body.addProperty("proxy", System.getenv("PROXY_URL"));
    body.addProperty("record", false);
    body.addProperty("headless", headless);
    for (int attempt = 1; ; attempt++) {
      HttpResponse<String> res = HTTP.send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/" + engine + "/session"))
          .header("x-api-key", KEY).header("content-type", "application/json")
          .POST(HttpRequest.BodyPublishers.ofString(body.toString())).build(), HttpResponse.BodyHandlers.ofString());
      if (res.statusCode() == 200) return JsonParser.parseString(res.body()).getAsJsonObject().get("wsUrl").getAsString();
      if ((res.statusCode() != 429 && res.statusCode() != 503) || attempt == 5) throw new RuntimeException("launch " + res.statusCode() + " " + res.body());
      Thread.sleep(Long.parseLong(res.headers().firstValue("retry-after").orElse("5")) * 1000);
    }
  }

  static BrowserContext siteContext(Browser browser) {
    BrowserContext context = browser.newContext();
    for (String url : SITES.values()) context.route(url + "**", route -> route.fulfill(new Route.FulfillOptions().setContentType("text/html").setBody(HTML)));
    return context;
  }

  // One visit = a new tab in the given context, the probe, the tab closed.
  static JsonObject visit(BrowserContext context, String label, int launchNo, int contextNo, String site) {
    Page page = context.newPage();
    try {
      page.navigate(SITES.get(site));
      JsonObject v = new JsonObject();
      v.addProperty("visit", label);
      v.addProperty("launch", launchNo);
      v.addProperty("context", contextNo);
      for (Map.Entry<String, JsonElement> e : GSON.toJsonTree(page.evaluate(PROBE)).getAsJsonObject().entrySet()) v.add(e.getKey(), e.getValue());
      return v;
    } finally {
      page.close();
    }
  }

  public static void main(String[] args) throws Exception {
    JsonArray setups = new JsonArray();
    try (Playwright playwright = Playwright.create()) {
      BrowserType.ConnectOptions auth = new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY));
      for (Setup s : SETUPS) {
        JsonObject row = new JsonObject();
        row.addProperty("setup", s.name());
        row.addProperty("engine", s.engine());
        row.addProperty("headless", s.headless());
        JsonArray visits = new JsonArray();
        JsonObject pages = new JsonObject();
        // Launch 1: site A, site B, site A again in a new tab, then site A in a second context.
        Browser browser = playwright.chromium().connect(launch(s.engine(), s.headless()), auth);
        try {
          BrowserContext one = siteContext(browser);
          visits.add(visit(one, "site A", 1, 1, "A"));
          visits.add(visit(one, "site B", 1, 1, "B"));
          visits.add(visit(one, "site A again", 1, 1, "A"));
          BrowserContext two = siteContext(browser);
          visits.add(visit(two, "site A, second context", 1, 2, "A"));
        } finally {
          browser.close();
        }
        // Launch 2: sites A and B again, then each public test page once, in a fresh context.
        browser = playwright.chromium().connect(launch(s.engine(), s.headless()), auth);
        try {
          BrowserContext three = siteContext(browser);
          visits.add(visit(three, "site A, next launch", 2, 1, "A"));
          visits.add(visit(three, "site B, next launch", 2, 1, "B"));
          Page page = browser.newContext().newPage();
          for (TestPage p : PAGES) {
            try {
              // A proxy tunnel drop is tried once more; a page that loaded is never reloaded.
              try {
                page.navigate(p.url(), new Page.NavigateOptions().setTimeout(60000));
              } catch (PlaywrightException first) {
                page.navigate(p.url(), new Page.NavigateOptions().setTimeout(60000));
              }
              page.waitForFunction(p.ready(), null, new Page.WaitForFunctionOptions().setTimeout(45000));
              pages.add(p.key(), GSON.toJsonTree(page.evaluate(p.read())));
            } catch (PlaywrightException err) {
              String msg = err.getMessage().split("\n")[0];
              JsonObject e = new JsonObject();
              e.addProperty("error", msg.length() > 120 ? msg.substring(0, 120) : msg);
              pages.add(p.key(), e);
            }
          }
        } finally {
          browser.close();
        }
        row.add("visits", visits);
        row.add("pages", pages);
        setups.add(row);
      }
    }
    JsonObject out = new JsonObject();
    out.add("setups", setups);
    System.out.println(GSON.toJson(out));
  }
}
```

```csharp
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL (an http(s) or socks5 proxy; one load per public test page per setup)
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();

var setups = new (string Name, string Engine, bool Headless)[]
{
    ("Brave, headless", "brave", true),
    ("Brave, headful", "brave", false),
    ("Google Chrome, headful", "chrome", false),
};

// Two made-up sites on different registrable domains, served by page routing: the browser
// treats them as two first-party sites, and no request leaves the browser.
var sites = new Dictionary<string, string> { ["A"] = "https://site-a.example/", ["B"] = "https://site-b.test/" };
const string Html = "<!doctype html><title>lab 83</title><p>A page with nothing on it but this probe.</p>";

// What a fingerprinting script reads, the same in every language: a canvas hash (drawn twice),
// an OfflineAudioContext hash, cores, memory, the plugin list, the screen and the window size.
const string Probe = """
    (async () => {
      const fnv = (s) => { let h = 0x811c9dc5; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; } return h.toString(16).padStart(8, '0'); };
      const draw = () => {
        const c = document.createElement('canvas'); c.width = 240; c.height = 60;
        const x = c.getContext('2d'); x.textBaseline = 'top'; x.font = '16px Arial';
        x.fillStyle = '#f60'; x.fillRect(100, 1, 62, 20);
        x.fillStyle = '#069'; x.fillText('cdpfleet fp 83 <@>', 2, 15);
        x.fillStyle = 'rgba(102, 204, 0, 0.7)'; x.fillText('cdpfleet fp 83 <@>', 4, 17);
        return c.toDataURL();
      };
      const audio = async () => {
        try {
          const ctx = new OfflineAudioContext(1, 44100, 44100);
          const o = ctx.createOscillator(); o.type = 'triangle'; o.frequency.value = 10000;
          const k = ctx.createDynamicsCompressor();
          k.threshold.value = -50; k.knee.value = 40; k.ratio.value = 12; k.attack.value = 0; k.release.value = 0.25;
          o.connect(k); k.connect(ctx.destination); o.start(0);
          const d = new Float32Array(500); (await ctx.startRendering()).copyFromChannel(d, 0, 4500);
          let s = ''; for (let i = 0; i < 500; i++) s += d[i].toFixed(10) + ',';
          return fnv(s);
        } catch (e) { return 'error: ' + e.name; }
      };
      const c1 = draw(), c2 = draw();
      const plugins = Array.from(navigator.plugins).map((p) => p.name);
      return {
        site: location.hostname,
        canvas: fnv(c1), canvas_same_in_page: c1 === c2,
        audio: await audio(),
        cores: navigator.hardwareConcurrency, memory: navigator.deviceMemory ?? null,
        plugins: fnv(plugins.join(',')), plugin_names: plugins,
        screen: screen.width + 'x' + screen.height, window: outerWidth + 'x' + outerHeight,
      };
    })()
    """;

// Each public test page's own verdict, read from its DOM; Ready is polled until the page has
// finished its checks. The same expressions run in every language.
var pages = new (string Key, string Url, string Ready, string Read)[]
{
    // A section CreepJS caught lying gets its hash marked with the "lies" class.
    ("creepjs", "https://abrahamjuliot.github.io/creepjs/",
        """/\d+% stealth/.test(document.body.innerText) && /privacy: \S/.test(document.body.innerText)""",
        """(() => { const t = document.body.innerText.replace(/\s+/g, ' '); const m = /(\d+)% like headless[\s\S]*?(\d+)% headless:[\s\S]*?(\d+)% stealth/.exec(t); const privacy = /privacy: (\S+)/.exec(t); const noise = /rendering: (\d+% \w+ noise)/.exec(t); return { like_headless_pct: m ? +m[1] : null, headless_pct: m ? +m[2] : null, stealth_pct: m ? +m[3] : null, privacy: privacy ? privacy[1] : null, canvas_noise: noise ? noise[1] : null, lies: Array.from(document.querySelectorAll('.lies')).map((e) => (e.previousElementSibling ? e.previousElementSibling.innerText.trim() : '?')) }; })()"""),
    ("browserleaks", "https://browserleaks.com/canvas",
        """/Uniqueness\s+\d/.test(document.body.innerText)""",
        """(() => { const t = document.body.innerText; const sig = /Signature\s+([0-9A-Fa-f]{32})/.exec(t); const u = /Uniqueness\s+([^\n]+)/.exec(t); return { signature: sig ? sig[1] : null, uniqueness: u ? u[1].trim() : null }; })()"""),
    ("sannysoft", "https://bot.sannysoft.com/",
        """document.querySelectorAll('td.passed, td.failed').length > 10""",
        """(() => { const rows = Array.from(document.querySelectorAll('tr')).filter((tr) => tr.querySelector('td.passed, td.failed, td.warn')); const failed = rows.filter((tr) => tr.querySelector('td.failed')).map((tr) => tr.cells[0].innerText.replace(/\s+/g, ' ').trim()); return { checks: rows.length, failed: failed.length, failed_names: failed }; })()"""),
    ("deviceandbrowserinfo", "https://deviceandbrowserinfo.com/are_you_a_bot",
        """document.body.innerText.indexOf('"isBot"') >= 0""",
        """(() => { const t = document.body.innerText; const s = t.indexOf('{', t.indexOf('"isBot"') - 10); let depth = 0, e = s; for (; e < t.length; e++) { if (t[e] === '{') depth++; if (t[e] === '}' && --depth === 0) break; } try { const j = JSON.parse(t.slice(s, e + 1)); return { is_bot: j.isBot, signals: Object.keys(j.details || {}).filter((k) => j.details[k] === true) }; } catch (err) { return { is_bot: null, signals: [] }; } })()"""),
    // Only the bot verdict and the suspect score are read (the page also names proxy networks).
    ("fingerprint", "https://demo.fingerprint.com/playground",
        """/Suspect Score \d+/.test(document.body.innerText.replace(/\s+/g, ' '))""",
        """(() => { const t = document.body.innerText.replace(/\s+/g, ' '); const bot = /\bBot (.+?) VPN /.exec(t); const score = /Suspect Score (\d+)/.exec(t); return { bot: bot ? bot[1].replace(/[^\x20-\x7e]/g, '').replace(/\s+/g, ' ').trim() : null, suspect_score: score ? +score[1] : null }; })()"""),
};

// 503 means the fleet is momentarily busy: wait Retry-After and try again.
async Task<string> Launch(string engine, bool headless)
{
    var body = new JsonObject { ["proxy"] = proxy, ["record"] = false, ["headless"] = headless };
    for (var attempt = 1; ; attempt++)
    {
        var res = await http.PostAsJsonAsync($"https://starter.cdpfleet.com/{engine}/session", body);
        if (res.IsSuccessStatusCode) return (await res.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("wsUrl").GetString()!;
        var code = (int)res.StatusCode;
        if ((code != 429 && code != 503) || attempt == 5) throw new Exception($"launch {code} {await res.Content.ReadAsStringAsync()}");
        await Task.Delay(TimeSpan.FromSeconds(res.Headers.RetryAfter?.Delta?.TotalSeconds ?? 5));
    }
}

async Task<IBrowserContext> SiteContext(IBrowser browser)
{
    var context = await browser.NewContextAsync();
    foreach (var url in sites.Values) await context.RouteAsync(url + "**", route => route.FulfillAsync(new() { ContentType = "text/html", Body = Html }));
    return context;
}

// One visit = a new tab in the given context, the probe, the tab closed.
async Task<JsonObject> Visit(IBrowserContext context, string label, int launchNo, int contextNo, string site)
{
    var page = await context.NewPageAsync();
    try
    {
        await page.GotoAsync(sites[site]);
        var v = new JsonObject { ["visit"] = label, ["launch"] = launchNo, ["context"] = contextNo };
        foreach (var (k, x) in JsonNode.Parse((await page.EvaluateAsync<JsonElement>(Probe)).GetRawText())!.AsObject()) v[k] = x?.DeepClone();
        return v;
    }
    finally
    {
        await page.CloseAsync();
    }
}

var auth = new BrowserTypeConnectOptions { Headers = new Dictionary<string, string> { ["x-api-key"] = key } };
var rows = new JsonArray();
foreach (var s in setups)
{
    var visits = new JsonArray();
    var verdicts = new JsonObject();
    // Launch 1: site A, site B, site A again in a new tab, then site A in a second context.
    var browser = await playwright.Chromium.ConnectAsync(await Launch(s.Engine, s.Headless), auth);
    try
    {
        var one = await SiteContext(browser);
        visits.Add(await Visit(one, "site A", 1, 1, "A"));
        visits.Add(await Visit(one, "site B", 1, 1, "B"));
        visits.Add(await Visit(one, "site A again", 1, 1, "A"));
        var two = await SiteContext(browser);
        visits.Add(await Visit(two, "site A, second context", 1, 2, "A"));
    }
    finally
    {
        await browser.CloseAsync();
    }
    // Launch 2: sites A and B again, then each public test page once, in a fresh context.
    browser = await playwright.Chromium.ConnectAsync(await Launch(s.Engine, s.Headless), auth);
    try
    {
        var three = await SiteContext(browser);
        visits.Add(await Visit(three, "site A, next launch", 2, 1, "A"));
        visits.Add(await Visit(three, "site B, next launch", 2, 1, "B"));
        var page = await (await browser.NewContextAsync()).NewPageAsync();
        foreach (var p in pages)
        {
            try
            {
                // A proxy tunnel drop is tried once more; a page that loaded is never reloaded.
                try { await page.GotoAsync(p.Url, new() { Timeout = 60000 }); }
                catch (PlaywrightException) { await page.GotoAsync(p.Url, new() { Timeout = 60000 }); }
                await page.WaitForFunctionAsync(p.Ready, null, new() { Timeout = 45000 });
                verdicts[p.Key] = JsonNode.Parse((await page.EvaluateAsync<JsonElement>(p.Read)).GetRawText());
            }
            catch (Exception err)
            {
                var msg = err.Message.Split('\n')[0];
                verdicts[p.Key] = new JsonObject { ["error"] = msg.Length > 120 ? msg[..120] : msg };
            }
        }
    }
    finally
    {
        await browser.CloseAsync();
    }
    rows.Add(new JsonObject { ["setup"] = s.Name, ["engine"] = s.Engine, ["headless"] = s.Headless, ["visits"] = visits, ["pages"] = verdicts });
}
var output = new JsonObject { ["setups"] = rows };
Console.WriteLine(output.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
```

```go
// go get github.com/playwright-community/playwright-go@v0.6000.0
// Driver: build playwright-core 1.60.0 from npm and set PLAYWRIGHT_DRIVER_PATH (see /docs/quickstart).
// env: CDPFLEET_API_KEY, PROXY_URL (an http(s) or socks5 proxy; one load per public test page per setup)
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"

	"github.com/playwright-community/playwright-go"
)

var key = os.Getenv("CDPFLEET_API_KEY")

type setup struct {
	Name, Engine string
	Headless     bool
}

var setups = []setup{
	{"Brave, headless", "brave", true},
	{"Brave, headful", "brave", false},
	{"Google Chrome, headful", "chrome", false},
}

// Two made-up sites on different registrable domains, served by page routing: the browser
// treats them as two first-party sites, and no request leaves the browser.
var sites = map[string]string{"A": "https://site-a.example/", "B": "https://site-b.test/"}

const html = "<!doctype html><title>lab 83</title><p>A page with nothing on it but this probe.</p>"

// What a fingerprinting script reads, the same in every language: a canvas hash (drawn twice),
// an OfflineAudioContext hash, cores, memory, the plugin list, the screen and the window size.
const probe = `(async () => {
  const fnv = (s) => { let h = 0x811c9dc5; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; } return h.toString(16).padStart(8, '0'); };
  const draw = () => {
    const c = document.createElement('canvas'); c.width = 240; c.height = 60;
    const x = c.getContext('2d'); x.textBaseline = 'top'; x.font = '16px Arial';
    x.fillStyle = '#f60'; x.fillRect(100, 1, 62, 20);
    x.fillStyle = '#069'; x.fillText('cdpfleet fp 83 <@>', 2, 15);
    x.fillStyle = 'rgba(102, 204, 0, 0.7)'; x.fillText('cdpfleet fp 83 <@>', 4, 17);
    return c.toDataURL();
  };
  const audio = async () => {
    try {
      const ctx = new OfflineAudioContext(1, 44100, 44100);
      const o = ctx.createOscillator(); o.type = 'triangle'; o.frequency.value = 10000;
      const k = ctx.createDynamicsCompressor();
      k.threshold.value = -50; k.knee.value = 40; k.ratio.value = 12; k.attack.value = 0; k.release.value = 0.25;
      o.connect(k); k.connect(ctx.destination); o.start(0);
      const d = new Float32Array(500); (await ctx.startRendering()).copyFromChannel(d, 0, 4500);
      let s = ''; for (let i = 0; i < 500; i++) s += d[i].toFixed(10) + ',';
      return fnv(s);
    } catch (e) { return 'error: ' + e.name; }
  };
  const c1 = draw(), c2 = draw();
  const plugins = Array.from(navigator.plugins).map((p) => p.name);
  return {
    site: location.hostname,
    canvas: fnv(c1), canvas_same_in_page: c1 === c2,
    audio: await audio(),
    cores: navigator.hardwareConcurrency, memory: navigator.deviceMemory ?? null,
    plugins: fnv(plugins.join(',')), plugin_names: plugins,
    screen: screen.width + 'x' + screen.height, window: outerWidth + 'x' + outerHeight,
  };
})()`

type testPage struct{ Key, URL, Ready, Read string }

// Each public test page's own verdict, read from its DOM; Ready is polled until the page has
// finished its checks. The same expressions run in every language.
var pages = []testPage{
	// A section CreepJS caught lying gets its hash marked with the "lies" class.
	{"creepjs", "https://abrahamjuliot.github.io/creepjs/",
		`/\d+% stealth/.test(document.body.innerText) && /privacy: \S/.test(document.body.innerText)`,
		`(() => { const t = document.body.innerText.replace(/\s+/g, ' '); const m = /(\d+)% like headless[\s\S]*?(\d+)% headless:[\s\S]*?(\d+)% stealth/.exec(t); const privacy = /privacy: (\S+)/.exec(t); const noise = /rendering: (\d+% \w+ noise)/.exec(t); return { like_headless_pct: m ? +m[1] : null, headless_pct: m ? +m[2] : null, stealth_pct: m ? +m[3] : null, privacy: privacy ? privacy[1] : null, canvas_noise: noise ? noise[1] : null, lies: Array.from(document.querySelectorAll('.lies')).map((e) => (e.previousElementSibling ? e.previousElementSibling.innerText.trim() : '?')) }; })()`},
	{"browserleaks", "https://browserleaks.com/canvas",
		`/Uniqueness\s+\d/.test(document.body.innerText)`,
		`(() => { const t = document.body.innerText; const sig = /Signature\s+([0-9A-Fa-f]{32})/.exec(t); const u = /Uniqueness\s+([^\n]+)/.exec(t); return { signature: sig ? sig[1] : null, uniqueness: u ? u[1].trim() : null }; })()`},
	{"sannysoft", "https://bot.sannysoft.com/",
		`document.querySelectorAll('td.passed, td.failed').length > 10`,
		`(() => { const rows = Array.from(document.querySelectorAll('tr')).filter((tr) => tr.querySelector('td.passed, td.failed, td.warn')); const failed = rows.filter((tr) => tr.querySelector('td.failed')).map((tr) => tr.cells[0].innerText.replace(/\s+/g, ' ').trim()); return { checks: rows.length, failed: failed.length, failed_names: failed }; })()`},
	{"deviceandbrowserinfo", "https://deviceandbrowserinfo.com/are_you_a_bot",
		`document.body.innerText.indexOf('"isBot"') >= 0`,
		`(() => { const t = document.body.innerText; const s = t.indexOf('{', t.indexOf('"isBot"') - 10); let depth = 0, e = s; for (; e < t.length; e++) { if (t[e] === '{') depth++; if (t[e] === '}' && --depth === 0) break; } try { const j = JSON.parse(t.slice(s, e + 1)); return { is_bot: j.isBot, signals: Object.keys(j.details || {}).filter((k) => j.details[k] === true) }; } catch (err) { return { is_bot: null, signals: [] }; } })()`},
	// Only the bot verdict and the suspect score are read (the page also names proxy networks).
	{"fingerprint", "https://demo.fingerprint.com/playground",
		`/Suspect Score \d+/.test(document.body.innerText.replace(/\s+/g, ' '))`,
		`(() => { const t = document.body.innerText.replace(/\s+/g, ' '); const bot = /\bBot (.+?) VPN /.exec(t); const score = /Suspect Score (\d+)/.exec(t); return { bot: bot ? bot[1].replace(/[^\x20-\x7e]/g, '').replace(/\s+/g, ' ').trim() : null, suspect_score: score ? +score[1] : null }; })()`},
}

type row struct {
	Setup    string           `json:"setup"`
	Engine   string           `json:"engine"`
	Headless bool             `json:"headless"`
	Visits   []map[string]any `json:"visits"`
	Pages    map[string]any   `json:"pages"`
}

// 503 means the fleet is momentarily busy: wait Retry-After and try again.
func launch(engine string, headless bool) string {
	body, _ := json.Marshal(map[string]any{"proxy": os.Getenv("PROXY_URL"), "record": false, "headless": headless})
	for attempt := 1; ; attempt++ {
		req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/"+engine+"/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 {
			log.Fatal(err)
		}
		msg, _ := io.ReadAll(res.Body)
		res.Body.Close()
		if res.StatusCode == http.StatusOK {
			var session struct {
				WsURL string `json:"wsUrl"`
			}
			if err := json.Unmarshal(msg, &session); err != nil {
				log.Fatal(err)
			}
			return session.WsURL
		}
		if (res.StatusCode != 429 && res.StatusCode != 503) || attempt == 5 {
			log.Fatalf("launch %d %s", res.StatusCode, msg)
		}
		wait, _ := strconv.Atoi(res.Header.Get("retry-after"))
		if wait == 0 {
			wait = 5
		}
		time.Sleep(time.Duration(wait) * time.Second)
	}
}

func connect(pw *playwright.Playwright, s setup) playwright.Browser {
	browser, err := pw.Chromium.Connect(launch(s.Engine, s.Headless), playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
	if err != nil {
		log.Fatal(err)
	}
	return browser
}

func siteContext(browser playwright.Browser) playwright.BrowserContext {
	context, err := browser.NewContext()
	if err != nil {
		log.Fatal(err)
	}
	for _, url := range sites {
		if err := context.Route(url+"**", func(route playwright.Route) {
			route.Fulfill(playwright.RouteFulfillOptions{ContentType: playwright.String("text/html"), Body: html})
		}); err != nil {
			log.Fatal(err)
		}
	}
	return context
}

// One visit = a new tab in the given context, the probe, the tab closed.
func visit(context playwright.BrowserContext, label string, launchNo, contextNo int, site string) map[string]any {
	page, err := context.NewPage()
	if err != nil {
		log.Fatal(err)
	}
	defer page.Close()
	if _, err := page.Goto(sites[site]); err != nil {
		log.Fatal(err)
	}
	v, err := page.Evaluate(probe)
	if err != nil {
		log.Fatal(err)
	}
	out := v.(map[string]any)
	out["visit"], out["launch"], out["context"] = label, launchNo, contextNo
	return out
}

func errorOf(err error) map[string]any {
	msg := strings.SplitN(err.Error(), "\n", 2)[0]
	if len(msg) > 120 {
		msg = msg[:120]
	}
	return map[string]any{"error": msg}
}

func verdict(page playwright.Page, p testPage) any {
	// A proxy tunnel drop is tried once more; a page that loaded is never reloaded.
	if _, err := page.Goto(p.URL, playwright.PageGotoOptions{Timeout: playwright.Float(60000)}); err != nil {
		if _, err := page.Goto(p.URL, playwright.PageGotoOptions{Timeout: playwright.Float(60000)}); err != nil {
			return errorOf(err)
		}
	}
	if _, err := page.WaitForFunction(p.Ready, nil, playwright.PageWaitForFunctionOptions{Timeout: playwright.Float(45000)}); err != nil {
		return errorOf(err)
	}
	v, err := page.Evaluate(p.Read)
	if err != nil {
		return errorOf(err)
	}
	return v
}

func run(pw *playwright.Playwright, s setup) row {
	r := row{Setup: s.Name, Engine: s.Engine, Headless: s.Headless, Pages: map[string]any{}}
	// Launch 1: site A, site B, site A again in a new tab, then site A in a second context.
	browser := connect(pw, s)
	one := siteContext(browser)
	r.Visits = append(r.Visits, visit(one, "site A", 1, 1, "A"), visit(one, "site B", 1, 1, "B"), visit(one, "site A again", 1, 1, "A"))
	r.Visits = append(r.Visits, visit(siteContext(browser), "site A, second context", 1, 2, "A"))
	browser.Close()
	// Launch 2: sites A and B again, then each public test page once, in a fresh context.
	browser = connect(pw, s)
	defer browser.Close()
	three := siteContext(browser)
	r.Visits = append(r.Visits, visit(three, "site A, next launch", 2, 1, "A"), visit(three, "site B, next launch", 2, 1, "B"))
	ctx, err := browser.NewContext()
	if err != nil {
		log.Fatal(err)
	}
	page, err := ctx.NewPage()
	if err != nil {
		log.Fatal(err)
	}
	for _, p := range pages {
		r.Pages[p.Key] = verdict(page, p)
	}
	return r
}

func main() {
	pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
	if err != nil {
		log.Fatal(err)
	}
	defer pw.Stop()
	out := map[string][]row{"setups": {}}
	for _, s := range setups {
		out["setups"] = append(out["setups"], run(pw, s))
	}
	b, _ := json.MarshalIndent(out, "", "  ")
	fmt.Println(string(b))
}
```

## What we got

| Setup | Site A vs site B (one session) | Site A again (new tab) | Site A, second context | Site A, next launch | Cores · memory · screen (6 visits) | CreepJS: privacy · lies · headless | browserleaks canvas | sannysoft · deviceandbrowserinfo · fingerprint.com |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| Brave, headless | canvas, audio, cores, memory, plugins, window size differ | all the same | canvas, audio, cores, memory, plugins, window size differ | canvas, audio, cores, plugins, window size differ | 39, 45, 17, 14, 7 · 16, 8, 4 GB · 1366x768 | Brave · Screen, Canvas 2d, Audio, Navigator · 67% | 100% (The signature is unique to our database) | 4 of 31 failed (User Agent (Old), WebGL Renderer, HEADCHR\_UA, CHR\_MEMORY) · bot · You are a bad bot (type: headless\_chrome), score 41 |
| Brave, headful | canvas, audio, cores, memory, plugins, window size differ | all the same | canvas, audio, cores, plugins, window size differ | canvas, audio, cores, plugins, window size differ | 10, 62, 50, 33, 29 · 16, 8, 4 GB · 1366x768 | Brave · Screen, Canvas 2d, Audio, Navigator · 0% | 100% (The signature is unique to our database) | 1 of 31 failed (WebGL Renderer) · bot · Not detected, score 33 |
| Google Chrome, headful | all the same | all the same | all the same | cores differ | 36, 64 · 32 GB · 1280x720 | unknown · none · 0% | 99.95% (119 of 258037 user agents have the same signature) | 1 of 31 failed (WebGL Renderer) · bot · You are a bad bot (type: chromium\_automation), score 47 |

From the Node.js run on 2026-11-05. 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)

```
{
  "setups": [
    {
      "setup": "Brave, headless",
      "engine": "brave",
      "headless": true,
      "visits": [
        {
          "visit": "site A",
          "launch": 1,
          "context": 1,
          "site": "site-a.example",
          "canvas": "3929b51c",
          "canvas_same_in_page": true,
          "audio": "7d88c56c",
          "cores": 39,
          "memory": 16,
          "plugins": "3517aa9c",
          "plugin_names": [
            "SRIMtWq",
            "PDF Viewer",
            "Online com.adobe.pdf extension",
            "Chromium PDF Viewer",
            "Microsoft Edge PDF Viewer",
            "WebKit built-in PDF",
            "o7GDBfPH"
          ],
          "screen": "1366x768",
          "window": "1287x723"
        },
        {
          "visit": "site B",
          "launch": 1,
          "context": 1,
          "site": "site-b.test",
          "canvas": "a6d8dc39",
          "canvas_same_in_page": true,
          "audio": "ccfc6c8e",
          "cores": 45,
          "memory": 8,
          "plugins": "c18a66df",
          "plugin_names": [
            "Rnb0DozZ",
            "WebKit built-in PDF",
            "OpenSource PDF plug in",
            "myCBfXq",
            "Chromium PDF Viewer",
            "Microsoft Edge PDF Viewer",
            "PDF Viewer"
          ],
          "screen": "1366x768",
          "window": "1283x725"
        },
        {
          "visit": "site A again",
          "launch": 1,
          "context": 1,
          "site": "site-a.example",
          "canvas": "3929b51c",
          "canvas_same_in_page": true,
          "audio": "7d88c56c",
          "cores": 39,
          "memory": 16,
          "plugins": "3517aa9c",
          "plugin_names": [
            "SRIMtWq",
            "PDF Viewer",
            "Online com.adobe.pdf extension",
            "Chromium PDF Viewer",
            "Microsoft Edge PDF Viewer",
            "WebKit built-in PDF",
            "o7GDBfPH"
          ],
          "screen": "1366x768",
          "window": "1287x723"
        },
        {
          "visit": "site A, second context",
          "launch": 1,
          "context": 2,
          "site": "site-a.example",
          "canvas": "0f1dee48",
          "canvas_same_in_page": true,
          "audio": "6cc3bfb5",
          "cores": 17,
          "memory": 4,
          "plugins": "0f8c46b8",
          "plugin_names": [
            "Chromium PDF Viewer",
            "WebKit built-in PDF",
            "Microsoft Edge PDF Viewer",
            "PDF Viewer",
            "Co7GLs1D",
            "Chromium doc extension",
            "v68eu2E"
          ],
          "screen": "1366x768",
          "window": "1287x722"
        },
        {
          "visit": "site A, next launch",
          "launch": 2,
          "context": 1,
          "site": "site-a.example",
          "canvas": "f2fa3d3b",
          "canvas_same_in_page": true,
          "audio": "4957ea93",
          "cores": 14,
          "memory": 16,
          "plugins": "bf2c840b",
          "plugin_names": [
            "PDF Viewer",
            "WebKit built-in PDF",
            "Microsoft Edge PDF Viewer",
            "QQnTJjR",
            "Chromium PDF Viewer",
            "157laVSR",
            "Chromium PDF Viewer"
          ],
          "screen": "1366x768",
          "window": "1283x727"
        },
        {
          "visit": "site B, next launch",
          "launch": 2,
          "context": 1,
          "site": "site-b.test",
          "canvas": "03b82bb5",
          "canvas_same_in_page": true,
          "audio": "59fe9049",
          "cores": 7,
          "memory": 4,
          "plugins": "d777bd16",
          "plugin_names": [
            "Microsoft Edge PDF Viewer",
            "Chromium PDF Viewer",
            "WyhQIECg",
            "Online PDF and PS plug-in",
            "WebKit built-in PDF",
            "PDF Viewer",
            "zhQQnTR"
          ],
          "screen": "1366x768",
          "window": "1284x724"
        }
      ],
      "pages": {
        "creepjs": {
          "like_headless_pct": 50,
          "headless_pct": 67,
          "stealth_pct": 0,
          "privacy": "Brave",
          "canvas_noise": "13% rgba noise",
          "lies": [
            "Screen",
            "Canvas 2d",
            "Audio",
            "Navigator"
          ]
        },
        "browserleaks": {
          "signature": "5665EF9CFB4247140C85C0EF68D6E19A",
          "uniqueness": "100% (The signature is unique to our database)"
        },
        "sannysoft": {
          "checks": 31,
          "failed": 4,
          "failed_names": [
            "User Agent (Old)",
            "WebGL Renderer",
            "HEADCHR_UA",
            "CHR_MEMORY"
          ]
        },
        "deviceandbrowserinfo": {
          "is_bot": true,
          "signals": [
            "hasBotUserAgent",
            "hasInconsistentTimingResolution",
            "isAutomatedWithCDP",
            "isAutomatedWithCDPInWebWorker",
            "hasSuspiciousWeakSignals"
          ]
        },
        "fingerprint": {
          "bot": "You are a bad bot (type: headless_chrome)",
          "suspect_score": 41
        }
      }
    },
    {
      "setup": "Brave, headful",
      "engine": "brave",
      "headless": false,
      "visits": [
        {
          "visit": "site A",
          "launch": 1,
          "context": 1,
          "site": "site-a.example",
          "canvas": "78aaddc1",
          "canvas_same_in_page": true,
          "audio": "260fc5cf",
          "cores": 10,
          "memory": 16,
          "plugins": "7eb2a6c8",
          "plugin_names": [
            "aRvAfPHD",
            "Microsoft Edge PDF Viewer",
            "VpzZz4k",
            "WebKit built-in PDF",
            "PDF Viewer",
            "Chromium PDF Renderer",
            "Chromium PDF Viewer"
          ],
          "screen": "1366x768",
          "window": "1284x726"
        },
        {
          "visit": "site B",
          "launch": 1,
          "context": 1,
          "site": "site-b.test",
          "canvas": "c47f4c44",
          "canvas_same_in_page": true,
          "audio": "8a2ad350",
          "cores": 62,
          "memory": 8,
          "plugins": "acbec43b",
          "plugin_names": [
            "Microsoft Edge PDF Viewer",
            "Chromium PDF Viewer",
            "BMl5cWL",
            "PDF Viewer",
            "nTRQvAny",
            "WebKit built-in PDF",
            "OpenSource com.adobe.pdf Plugin"
          ],
          "screen": "1366x768",
          "window": "1285x722"
        },
        {
          "visit": "site A again",
          "launch": 1,
          "context": 1,
          "site": "site-a.example",
          "canvas": "78aaddc1",
          "canvas_same_in_page": true,
          "audio": "260fc5cf",
          "cores": 10,
          "memory": 16,
          "plugins": "7eb2a6c8",
          "plugin_names": [
            "aRvAfPHD",
            "Microsoft Edge PDF Viewer",
            "VpzZz4k",
            "WebKit built-in PDF",
            "PDF Viewer",
            "Chromium PDF Renderer",
            "Chromium PDF Viewer"
          ],
          "screen": "1366x768",
          "window": "1284x726"
        },
        {
          "visit": "site A, second context",
          "launch": 1,
          "context": 2,
          "site": "site-a.example",
          "canvas": "8b9aa4aa",
          "canvas_same_in_page": true,
          "audio": "b8f8904c",
          "cores": 50,
          "memory": 16,
          "plugins": "f3722525",
          "plugin_names": [
            "WebKit built-in PDF",
            "ylSw3j4F",
            "Microsoft Edge PDF Viewer",
            "uAfPPmy",
            "PDF Viewer",
            "Chromium PDF Viewer",
            "Chromium document plug-in"
          ],
          "screen": "1366x768",
          "window": "1283x728"
        },
        {
          "visit": "site A, next launch",
          "launch": 2,
          "context": 1,
          "site": "site-a.example",
          "canvas": "e89d04a6",
          "canvas_same_in_page": true,
          "audio": "f6955e75",
          "cores": 33,
          "memory": 16,
          "plugins": "c101b944",
          "plugin_names": [
            "PDF Viewer",
            "Chromium PDF Viewer",
            "PPm6dt15",
            "WebKit built-in PDF",
            "Brave Portable Document Format ",
            "Microsoft Edge PDF Viewer",
            "rVp78mT"
          ],
          "screen": "1366x768",
          "window": "1281x725"
        },
        {
          "visit": "site B, next launch",
          "launch": 2,
          "context": 1,
          "site": "site-b.test",
          "canvas": "609dba8e",
          "canvas_same_in_page": true,
          "audio": "f7932c8b",
          "cores": 29,
          "memory": 4,
          "plugins": "8b2aba84",
          "plugin_names": [
            "yhYzCBn",
            "Chromium com.adobe.pdf plug-in",
            "1Do78eu2",
            "WebKit built-in PDF",
            "Microsoft Edge PDF Viewer",
            "Chromium PDF Viewer",
            "PDF Viewer"
          ],
          "screen": "1366x768",
          "window": "1287x728"
        }
      ],
      "pages": {
        "creepjs": {
          "like_headless_pct": 50,
          "headless_pct": 0,
          "stealth_pct": 0,
          "privacy": "Brave",
          "canvas_noise": "11% rgba noise",
          "lies": [
            "Screen",
            "Canvas 2d",
            "Audio",
            "Navigator"
          ]
        },
        "browserleaks": {
          "signature": "81495C5956A1E9C8878409147E273E0F",
          "uniqueness": "100% (The signature is unique to our database)"
        },
        "sannysoft": {
          "checks": 31,
          "failed": 1,
          "failed_names": [
            "WebGL Renderer"
          ]
        },
        "deviceandbrowserinfo": {
          "is_bot": true,
          "signals": [
            "hasInconsistentTimingResolution",
            "isAutomatedWithCDP",
            "isAutomatedWithCDPInWebWorker",
            "hasSuspiciousWeakSignals"
          ]
        },
        "fingerprint": {
          "bot": "Not detected",
          "suspect_score": 33
        }
      }
    },
    {
      "setup": "Google Chrome, headful",
      "engine": "chrome",
      "headless": false,
      "visits": [
        {
          "visit": "site A",
          "launch": 1,
          "context": 1,
          "site": "site-a.example",
          "canvas": "a30ef280",
          "canvas_same_in_page": true,
          "audio": "b7a0343c",
          "cores": 36,
          "memory": 32,
          "plugins": "1b059b58",
          "plugin_names": [
            "PDF Viewer",
            "Chrome PDF Viewer",
            "Chromium PDF Viewer",
            "Microsoft Edge PDF Viewer",
            "WebKit built-in PDF"
          ],
          "screen": "1280x720",
          "window": "1288x805"
        },
        {
          "visit": "site B",
          "launch": 1,
          "context": 1,
          "site": "site-b.test",
          "canvas": "a30ef280",
          "canvas_same_in_page": true,
          "audio": "b7a0343c",
          "cores": 36,
          "memory": 32,
          "plugins": "1b059b58",
          "plugin_names": [
            "PDF Viewer",
            "Chrome PDF Viewer",
            "Chromium PDF Viewer",
            "Microsoft Edge PDF Viewer",
            "WebKit built-in PDF"
          ],
          "screen": "1280x720",
          "window": "1288x805"
        },
        {
          "visit": "site A again",
          "launch": 1,
          "context": 1,
          "site": "site-a.example",
          "canvas": "a30ef280",
          "canvas_same_in_page": true,
          "audio": "b7a0343c",
          "cores": 36,
          "memory": 32,
          "plugins": "1b059b58",
          "plugin_names": [
            "PDF Viewer",
            "Chrome PDF Viewer",
            "Chromium PDF Viewer",
            "Microsoft Edge PDF Viewer",
            "WebKit built-in PDF"
          ],
          "screen": "1280x720",
          "window": "1288x805"
        },
        {
          "visit": "site A, second context",
          "launch": 1,
          "context": 2,
          "site": "site-a.example",
          "canvas": "a30ef280",
          "canvas_same_in_page": true,
          "audio": "b7a0343c",
          "cores": 36,
          "memory": 32,
          "plugins": "1b059b58",
          "plugin_names": [
            "PDF Viewer",
            "Chrome PDF Viewer",
            "Chromium PDF Viewer",
            "Microsoft Edge PDF Viewer",
            "WebKit built-in PDF"
          ],
          "screen": "1280x720",
          "window": "1288x805"
        },
        {
          "visit": "site A, next launch",
          "launch": 2,
          "context": 1,
          "site": "site-a.example",
          "canvas": "a30ef280",
          "canvas_same_in_page": true,
          "audio": "b7a0343c",
          "cores": 64,
          "memory": 32,
          "plugins": "1b059b58",
          "plugin_names": [
            "PDF Viewer",
            "Chrome PDF Viewer",
            "Chromium PDF Viewer",
            "Microsoft Edge PDF Viewer",
            "WebKit built-in PDF"
          ],
          "screen": "1280x720",
          "window": "1288x805"
        },
        {
          "visit": "site B, next launch",
          "launch": 2,
          "context": 1,
          "site": "site-b.test",
          "canvas": "a30ef280",
          "canvas_same_in_page": true,
          "audio": "b7a0343c",
          "cores": 64,
          "memory": 32,
          "plugins": "1b059b58",
          "plugin_names": [
            "PDF Viewer",
            "Chrome PDF Viewer",
            "Chromium PDF Viewer",
            "Microsoft Edge PDF Viewer",
            "WebKit built-in PDF"
          ],
          "screen": "1280x720",
          "window": "1288x805"
        }
      ],
      "pages": {
        "creepjs": {
          "like_headless_pct": 50,
          "headless_pct": 0,
          "stealth_pct": 0,
          "privacy": "unknown",
          "canvas_noise": null,
          "lies": []
        },
        "browserleaks": {
          "signature": "D64418DFFDD77D1444AE9BD3549AEFEE",
          "uniqueness": "99.95% (119 of 258037 user agents have the same signature)"
        },
        "sannysoft": {
          "checks": 31,
          "failed": 1,
          "failed_names": [
            "WebGL Renderer"
          ]
        },
        "deviceandbrowserinfo": {
          "is_bot": true,
          "signals": [
            "hasInconsistentTimingResolution",
            "isAutomatedWithCDP",
            "isAutomatedWithCDPInWebWorker",
            "hasSuspiciousWeakSignals"
          ]
        },
        "fingerprint": {
          "bot": "You are a bad bot (type: chromium_automation)",
          "suspect_score": 47
        }
      }
    }
  ]
}
```

## Takeaways

-   **On 2026-11-05, Brave showed two different sites in the same session two different devices.** Across five runs (one per language) and both Brave setups, site A and site B got a different canvas hash, audio hash, plugin list and window size in 20 of 20 same-session pairs, different `hardwareConcurrency` in 19 and different `deviceMemory` in 15. Headful Chrome gave both sites exactly the same values every time. So these values can't tie one Brave session's visit to one site to its visit to another.
-   **The same site got the same values back within a session.** Site A in a new tab of the same context matched the first visit on all seven signals in all 10 Brave sessions: a site sees one stable browser for as long as the session lasts.
-   **A second browser context in the same session counted as a new session.** Site A in a second `browser.newContext()` got a new canvas, audio, plugin list, core count and window size in 10 of 10 sessions (memory in 8 — it only has four values to pick from). With automation every new context is a new Brave seed; to keep one site's values, keep its context.
-   **The next launch changed everything for both sites again**, as [case 66](https://cdpfleet.com/docs/cases/fingerprint-stability-across-launches) found for one site, which also covers profiles, WebGL and fonts.
-   **Cores, memory and plugins were made up.** Brave reported `hardwareConcurrency` from 4 to 63 and `deviceMemory` of 4, 8, 16 or 32 across its 60 visits. Chrome reported the server's real core count (16, 24, 36 or 64, the same within every session — it changed between launches only when the session landed on a different server) and 32 GB every time. Brave's plugin list always had 7 entries: the four PDF viewer names it shares with Chrome (`PDF Viewer`, `Chromium PDF Viewer`, `Microsoft Edge PDF Viewer`, `WebKit built-in PDF`), two random strings and one made-up PDF plug-in name such as `Online com.adobe.pdf extension` (once it came out as a second `Chromium PDF Viewer`), in shuffled order; Chrome listed the standard five.
-   **Brave didn't report Playwright's viewport as its screen.** With Playwright's default 1280×720 viewport, Chrome reported a 1280×720 screen and a 1288×805 window on all 30 visits. Brave reported a 1366×768 screen on 55 of 60 visits and 1280×800 on 5 (in both modes), and its `outerWidth × outerHeight` was the viewport plus 0–8 px in each direction, picked per site.
-   **CreepJS recognised Brave and marked its noise as lies.** In all 10 Brave loads it named the privacy feature "Brave", estimated the canvas noise at 11–13% of rgba values and marked the Screen, Canvas 2d, Audio and Navigator sections as lies; for Chrome it named no privacy feature and marked nothing, 5 of 5. Its headless score was 67% for Brave headless (which still sends a `HeadlessChrome` user agent) and 0% for Brave headful and Chrome; "like headless" was 50% and stealth 0% for all three.
-   **browserleaks called Brave's canvas unique, Chrome's common.** All 10 Brave canvas signatures were different and each was "unique to our database"; Chrome got the same signature in all 5 runs, shared by 119 of 258,037 user agents in browserleaks' database. Noise makes every visit unique — which also means no other visitor looks like it.
-   **The bot-test pages judged automation, not farbling.** sannysoft failed Brave headless on 4 checks (`User Agent (Old)`, `HEADCHR_UA`, `CHR_MEMORY`, WebGL renderer) and Brave headful and Chrome on the WebGL renderer only; deviceandbrowserinfo called all three a bot in every run for CDP automation (plus a bot user agent for Brave headless). The fingerprint.com playground called Brave headless a `headless_chrome` bad bot and Chrome a `chromium_automation` bad bot in 5 of 5 runs each, but said "Not detected" for headful Brave in 5 of 5 (suspect score 29–40, against 36–52 for Chrome). That is one page on one day; verdicts vary from load to load, and a public test page isn't a protected site — see [case 63](https://cdpfleet.com/docs/cases/what-fingerprint-test-pages-see) for these pages on other browsers and the lab cases for real sites.

---
Source: https://cdpfleet.com/docs/cases/brave-farbling · Markdown: https://cdpfleet.com/docs/cases/brave-farbling.md · All docs for AI tools: https://cdpfleet.com/llms.txt
