cdpfleet Docs GitHub Dashboard

Cases / #4 · 2026-09-30 · Hard

A German Windows persona with Camoufox — and the one setting that broke it

OS, locale, screen, window, WebRTC and geo-IP in one consistent fingerprint. The proxy decides whether the story holds.

Camoufox

Run on production on 2026-09-30: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go

The problem

Camoufox (a hardened Firefox) generates a whole device fingerprint from a few options. The goal: a German user on a Windows desktop — German language headers, a Windows user agent and platform, a common screen size, no WebRTC leak, and a timezone that matches where the IP is. We run the same persona twice: through a random residential exit, and through one in Germany.

What we used, and why

WhatWhy
os: "windows"Windows user agent, navigator.platform: Win32, Windows-typical fonts and GPU strings.
locale: "de-DE"navigator.languages and the Accept-Language header in German.
screen, windowPin a common 1920×1080 screen and a 1600×900 window instead of random sizes.
humanize: trueHuman-like cursor movement for anything you click.
block_webrtc: trueNo RTCPeerConnection, so WebRTC can't leak the real network path.
geoip: trueTimezone and geolocation follow the proxy's exit IP. This is the option that makes or breaks the persona.
PROXY_URL_DEThe same residential provider with a German exit (most providers take a country in the username, e.g. -country-de).

How it works

  1. Launch Camoufox with the persona through a random exit, read what the page and the network see, then look up the exit IP's country.
  2. Do the same through a German exit.
  3. Compare: do language, timezone and exit country tell the same story?

The code

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

// npm install [email protected]
// env: CDPFLEET_API_KEY, PROXY_URL (any exit), PROXY_URL_DE (an exit in Germany)
import { firefox } from 'playwright';

const KEY = process.env.CDPFLEET_API_KEY;

// A German Windows desktop: every value below is part of one consistent story.
const persona = (proxy) => ({
  proxy,
  headless: true,
  os: 'windows',
  locale: 'de-DE',
  screen: { minWidth: 1920, maxWidth: 1920, minHeight: 1080, maxHeight: 1080 },
  window: [1600, 900],
  humanize: true,
  block_webrtc: true,
  geoip: true, // timezone and geolocation follow the proxy's exit IP
});

async function run(proxy) {
  const res = await fetch('https://starter.cdpfleet.com/camoufox/session', {
    method: 'POST',
    headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
    body: JSON.stringify(persona(proxy)),
  });
  if (!res.ok) throw new Error(`launch: ${res.status} ${await res.text()}`);
  const { wsUrl } = await res.json();
  const browser = await firefox.connect(wsUrl, { headers: { 'x-api-key': KEY } });
  try {
    const page = await browser.newPage();
    const fp = await (await page.goto('https://tls.peet.ws/api/all', { timeout: 60000 })).json();
    const seen = await page.evaluate(() => {
      const gl = document.createElement('canvas').getContext('webgl');
      const dbg = gl?.getExtension('WEBGL_debug_renderer_info');
      return {
        platform: navigator.platform,
        languages: navigator.languages,
        timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
        screen: `${screen.width}x${screen.height}`,
        window: `${outerWidth}x${outerHeight}`,
        hardware_concurrency: navigator.hardwareConcurrency,
        webgl_renderer: dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : null,
        webrtc: typeof RTCPeerConnection !== 'undefined',
      };
    });
    const headers = fp.http2.sent_frames.find((f) => f.frame_type === 'HEADERS').headers;
    // Where the proxy exits, as a website would look it up.
    const geo = await (await page.goto('http://ip-api.com/json/?fields=country,timezone', { timeout: 60000 })).json();
    return {
      exit_country: geo.country,
      exit_timezone: geo.timezone,
      user_agent: fp.user_agent,
      accept_language: headers.find((h) => h.startsWith('accept-language: '))?.slice(17) ?? null,
      ...seen,
      ja4: fp.tls.ja4,
    };
  } finally {
    await browser.close();
  }
}

console.log(JSON.stringify({
  'random exit': await run(process.env.PROXY_URL),
  'German exit': await run(process.env.PROXY_URL_DE),
}, null, 2));

What we got

ExitIP countryBrowser timezoneAccept-LanguagePlatformScreenWindowWebRTC
random exitUnited StatesAsia/Kuala_Lumpurde-DE,de;q=0.9Win321920x10801600x900no
German exitGermanyEurope/Berlinde-DE,de;q=0.9Win321920x10801600x900no

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)
{
  "random exit": {
    "exit_country": "United States",
    "exit_timezone": "America/New_York",
    "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0",
    "accept_language": "de-DE,de;q=0.9",
    "platform": "Win32",
    "languages": [
      "de-DE",
      "de"
    ],
    "timezone": "Asia/Kuala_Lumpur",
    "screen": "1920x1080",
    "window": "1600x900",
    "hardware_concurrency": 24,
    "webgl_renderer": "ANGLE (Intel, Intel(R) HD Graphics 400 Direct3D11 vs_5_0 ps_5_0), or similar",
    "webrtc": false,
    "ja4": "t13d1617h2_86a278354501_3cbfd9057e0d"
  },
  "German exit": {
    "exit_country": "Germany",
    "exit_timezone": "Europe/Berlin",
    "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0",
    "accept_language": "de-DE,de;q=0.9",
    "platform": "Win32",
    "languages": [
      "de-DE",
      "de"
    ],
    "timezone": "Europe/Berlin",
    "screen": "1920x1080",
    "window": "1600x900",
    "hardware_concurrency": 32,
    "webgl_renderer": "ANGLE (Intel, Intel(R) HD Graphics Direct3D11 vs_5_0 ps_5_0), or similar",
    "webrtc": false,
    "ja4": "t13d1617h2_86a278354501_3cbfd9057e0d"
  }
}

Takeaways