[Examples & benchmarks](https://cdpfleet.com/docs/cases) / #4 · 2026-09-30 · Example · Hard · for [web scraping](https://cdpfleet.com/web-scraping), [geo testing](https://cdpfleet.com/geo-testing)

# 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](https://cdpfleet.com/docs/browsers/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

| What | Why |
| --- | --- |
| `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`, `window` | Pin a common 1920×1080 screen and a 1600×900 window instead of random sizes. |
| `humanize: true` | Human-like cursor movement for anything you click. |
| `block_webrtc: true` | No `RTCPeerConnection`, so WebRTC can't leak the real network path. |
| `geoip: true` | Timezone and geolocation follow the proxy's exit IP. This is the option that makes or breaks the persona. |
| `PROXY_URL_DE` | The 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](https://github.com/cdpfleet/cdpfleet/tree/main/cases/04-camoufox-persona), 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`
-   `PROXY_URL_DE` — a proxy exit in Germany

 

```node
// npm install playwright@1.60.0
// 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));
```

```python
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL (any exit), PROXY_URL_DE (an exit in Germany)
import json
import os

import requests
from playwright.sync_api import sync_playwright

KEY = os.environ["CDPFLEET_API_KEY"]

def persona(proxy):
    # A German Windows desktop: every value below is part of one consistent story.
    return {
        "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
    }

PAGE_SIGNALS = """() => {
  const gl = document.createElement('canvas').getContext('webgl');
  const dbg = gl && 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',
  };
}"""

def run(p, proxy):
    res = requests.post("https://starter.cdpfleet.com/camoufox/session", headers={"x-api-key": KEY},
                        json=persona(proxy), timeout=60)
    res.raise_for_status()
    browser = p.firefox.connect(res.json()["wsUrl"], headers={"x-api-key": KEY})
    try:
        page = browser.new_page()
        fp = page.goto("https://tls.peet.ws/api/all", timeout=60000).json()
        seen = page.evaluate(PAGE_SIGNALS)
        headers = next(f for f in fp["http2"]["sent_frames"] if f["frame_type"] == "HEADERS")["headers"]
        # Where the proxy exits, as a website would look it up.
        geo = 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": next((h[17:] for h in headers if h.startswith("accept-language: ")), None),
            **seen,
            "ja4": fp["tls"]["ja4"],
        }
    finally:
        browser.close()

with sync_playwright() as p:
    print(json.dumps({
        "random exit": run(p, os.environ["PROXY_URL"]),
        "German exit": run(p, os.environ["PROXY_URL_DE"]),
    }, 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 (any exit), PROXY_URL_DE (an exit in Germany)
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");

  // A German Windows desktop: every value below is part of one consistent story.
  static String persona(String proxy) {
    return """
        {
          "proxy": %s,
          "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
        }""".formatted(new Gson().toJson(proxy)); // geoip: timezone and location follow the exit IP
  }

  static final String PAGE_SIGNALS = """
      () => {
        const gl = document.createElement('canvas').getContext('webgl');
        const dbg = gl && 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',
        };
      }""";

  static JsonObject run(Playwright playwright, String proxy) throws Exception {
    HttpResponse<String> res = HttpClient.newHttpClient().send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/camoufox/session"))
        .header("x-api-key", KEY).header("content-type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofString(persona(proxy))).build(), HttpResponse.BodyHandlers.ofString());
    if (res.statusCode() != 200) throw new RuntimeException("launch: " + res.statusCode() + " " + res.body());
    String wsUrl = JsonParser.parseString(res.body()).getAsJsonObject().get("wsUrl").getAsString();
    Browser browser = playwright.firefox().connect(wsUrl, new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
    try {
      Page page = browser.newPage();
      JsonObject fp = JsonParser.parseString(page.navigate("https://tls.peet.ws/api/all",
          new Page.NavigateOptions().setTimeout(60000)).text()).getAsJsonObject();
      JsonObject seen = new Gson().toJsonTree(page.evaluate(PAGE_SIGNALS)).getAsJsonObject();
      String acceptLanguage = null;
      for (JsonElement f : fp.getAsJsonObject("http2").getAsJsonArray("sent_frames")) {
        if (!f.getAsJsonObject().get("frame_type").getAsString().equals("HEADERS")) continue;
        for (JsonElement h : f.getAsJsonObject().getAsJsonArray("headers")) {
          if (h.getAsString().startsWith("accept-language: ")) acceptLanguage = h.getAsString().substring(17);
        }
      }
      // Where the proxy exits, as a website would look it up.
      JsonObject geo = JsonParser.parseString(page.navigate("http://ip-api.com/json/?fields=country,timezone",
          new Page.NavigateOptions().setTimeout(60000)).text()).getAsJsonObject();
      JsonObject out = new JsonObject();
      out.add("exit_country", geo.get("country"));
      out.add("exit_timezone", geo.get("timezone"));
      out.add("user_agent", fp.get("user_agent"));
      out.addProperty("accept_language", acceptLanguage);
      for (String k : seen.keySet()) out.add(k, seen.get(k));
      out.add("ja4", fp.getAsJsonObject("tls").get("ja4"));
      return out;
    } finally {
      browser.close();
    }
  }

  public static void main(String[] args) throws Exception {
    try (Playwright playwright = Playwright.create()) {
      JsonObject out = new JsonObject();
      out.add("random exit", run(playwright, System.getenv("PROXY_URL")));
      out.add("German exit", run(playwright, System.getenv("PROXY_URL_DE")));
      System.out.println(new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create().toJson(out));
    }
  }
}
```

```csharp
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL (any exit), PROXY_URL_DE (an exit in Germany)
using System.Net.Http.Json;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Playwright;

var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
using var playwright = await Playwright.CreateAsync();

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

const string PageSignals = """
    () => {
      const gl = document.createElement('canvas').getContext('webgl');
      const dbg = gl && 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',
      };
    }
    """;

async Task<JsonObject> Run(string proxy)
{
    var res = await http.PostAsJsonAsync("https://starter.cdpfleet.com/camoufox/session", Persona(proxy));
    if (!res.IsSuccessStatusCode) throw new Exception($"launch: {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");
    var wsUrl = (await res.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("wsUrl").GetString()!;
    var browser = await playwright.Firefox.ConnectAsync(wsUrl, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
    try
    {
        var page = await browser.NewPageAsync();
        var fp = JsonNode.Parse(await (await page.GotoAsync("https://tls.peet.ws/api/all", new() { Timeout = 60000 }))!.TextAsync())!;
        var seen = JsonNode.Parse((await page.EvaluateAsync<JsonElement>(PageSignals)).GetRawText())!.AsObject();
        var headers = fp["http2"]!["sent_frames"]!.AsArray().First(f => (string?)f!["frame_type"] == "HEADERS")!["headers"]!.AsArray();
        // Where the proxy exits, as a website would look it up.
        var geo = JsonNode.Parse(await (await page.GotoAsync("http://ip-api.com/json/?fields=country,timezone", new() { Timeout = 60000 }))!.TextAsync())!;
        var outp = new JsonObject
        {
            ["exit_country"] = geo["country"]!.DeepClone(),
            ["exit_timezone"] = geo["timezone"]!.DeepClone(),
            ["user_agent"] = fp["user_agent"]!.DeepClone(),
            ["accept_language"] = headers.Select(h => (string)h!).FirstOrDefault(h => h.StartsWith("accept-language: "))?[17..],
        };
        foreach (var (k, v) in seen) outp[k] = v?.DeepClone();
        outp["ja4"] = fp["tls"]!["ja4"]!.DeepClone();
        return outp;
    }
    finally
    {
        await browser.CloseAsync();
    }
}

var result = new JsonObject
{
    ["random exit"] = await Run(Environment.GetEnvironmentVariable("PROXY_URL")!),
    ["German exit"] = await Run(Environment.GetEnvironmentVariable("PROXY_URL_DE")!),
};
Console.WriteLine(result.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 (any exit), PROXY_URL_DE (an exit in Germany)
package main

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

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

var key = os.Getenv("CDPFLEET_API_KEY")

func launch(name string, options map[string]any) (map[string]any, error) {
	body, _ := json.Marshal(options)
	req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/"+name+"/session", bytes.NewReader(body))
	req.Header.Set("x-api-key", key)
	req.Header.Set("content-type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()
	if res.StatusCode != http.StatusOK {
		msg, _ := io.ReadAll(res.Body)
		return nil, fmt.Errorf("launch %s: %s %s", name, res.Status, msg)
	}
	var session map[string]any
	return session, json.NewDecoder(res.Body).Decode(&session)
}

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

const pageSignals = `() => {
  const gl = document.createElement('canvas').getContext('webgl');
  const dbg = gl && 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',
  };
}`

func run(pw *playwright.Playwright, proxy string) map[string]any {
	session, err := launch("camoufox", persona(proxy))
	if err != nil {
		log.Fatal(err)
	}
	browser, err := pw.Firefox.Connect(session["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
	if err != nil {
		log.Fatal(err)
	}
	defer browser.Close()
	page, _ := browser.NewPage()
	res, err := page.Goto("https://tls.peet.ws/api/all", playwright.PageGotoOptions{Timeout: playwright.Float(60000)})
	if err != nil {
		log.Fatal(err)
	}
	var fp struct {
		UserAgent string `json:"user_agent"`
		TLS       struct {
			JA4 string `json:"ja4"`
		} `json:"tls"`
		HTTP2 struct {
			SentFrames []struct {
				FrameType string   `json:"frame_type"`
				Headers   []string `json:"headers"`
			} `json:"sent_frames"`
		} `json:"http2"`
	}
	res.JSON(&fp)
	seen, err := page.Evaluate(pageSignals)
	if err != nil {
		log.Fatal(err)
	}
	// Where the proxy exits, as a website would look it up.
	geoRes, err := page.Goto("http://ip-api.com/json/?fields=country,timezone", playwright.PageGotoOptions{Timeout: playwright.Float(60000)})
	if err != nil {
		log.Fatal(err)
	}
	var geo struct{ Country, Timezone string }
	geoRes.JSON(&geo)
	out := seen.(map[string]any)
	out["exit_country"], out["exit_timezone"], out["user_agent"], out["ja4"] = geo.Country, geo.Timezone, fp.UserAgent, fp.TLS.JA4
	out["accept_language"] = nil
	for _, f := range fp.HTTP2.SentFrames {
		for _, h := range f.Headers {
			if f.FrameType == "HEADERS" && strings.HasPrefix(h, "accept-language: ") {
				out["accept_language"] = h[17:]
			}
		}
	}
	return out
}

func main() {
	pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
	if err != nil {
		log.Fatal(err)
	}
	defer pw.Stop()
	out, _ := json.MarshalIndent(map[string]any{
		"random exit": run(pw, os.Getenv("PROXY_URL")),
		"German exit": run(pw, os.Getenv("PROXY_URL_DE")),
	}, "", "  ")
	fmt.Println(string(out))
}
```

## What we got

| Exit | IP country | Browser timezone | Accept-Language | Platform | Screen | Window | WebRTC |
| --- | --- | --- | --- | --- | --- | --- | --- |
| random exit | United States | Asia/Kuala\_Lumpur | de-DE,de;q=0.9 | Win32 | 1920x1080 | 1600x900 | no |
| German exit | Germany | Europe/Berlin | de-DE,de;q=0.9 | Win32 | 1920x1080 | 1600x900 | no |

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

-   **Everything Camoufox controls came out as asked** in both runs: Windows user agent and platform, `de-DE` languages and header, 1920×1080 screen, 1600×900 window, no WebRTC, a plausible Windows GPU.
-   **The random exit broke the story:** a German-speaking Windows user in whatever timezone the random exit happened to be in (see the table). `geoip` did its job — it matched the IP — but the IP didn't match the persona. **Choose the proxy country to match the locale.**
-   **Rotating residential proxies can split the story further:** the timezone is set from the exit IP at launch, and a rotating proxy may exit somewhere else a moment later — in some of our runs the browser timezone and the IP a website saw were in different countries. Use sticky sessions (same IP for the whole session) with `geoip`.
-   **The German exit is consistent end to end:** `de-DE`, `Europe/Berlin`, German IP.
-   Fingerprint values that Camoufox randomizes (GPU, core count) differ between sessions — that's intended: each session is a different "device".

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