Cases / #49 · 2026-10-15 · Medium
The same Camoufox device across relaunches: what a persistent profile keeps, and what it doesn't
A Camoufox profile brought back the same screen, cores, GPU and user agent — and the login — on every relaunch, where launches without one got a new device each time. Two things still change per launch: the canvas noise, and the timezone if the exit IP moves.
Run on production on 2026-10-15: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
The problem
Accounts that are used for weeks get flagged when every visit looks like a different computer. Camoufox builds a believable persona on each launch — which is exactly the problem for a long-lived account: a new screen, GPU and CPU count every time. Does a persistent profile really bring the same device back, along with the login, and what does a fingerprinting script still see change?
What we used, and why
| What | Why |
|---|---|
"profile": "…" | The first launch creates the profile — Camoufox stores the persona it generated with the cookies and storage; later launches restore it. Work in browser.contexts()[0]. |
"os": "windows" | Only used when the profile is created; later launches keep the stored persona whatever you pass. |
PROXY_URL_US | One country for every launch: Camoufox sets timezone and locale from the exit IP. |
| A small fingerprinting script | User agent, platform, CPU cores, screen, WebGL renderer and a canvas hash — read on httpbin.org, plus a cookie and a localStorage entry as the "login". |
GET / DELETE /v1/me/profiles | Wait until each save has landed before the next launch, and delete the profile at the end. |
How it works
- Launch Camoufox four times with one new profile; on the first launch set a session cookie and a localStorage value; on each launch read the fingerprint (drawing the canvas twice).
- Wait for the profile's new version to be saved between launches.
- Launch twice more without a profile, for contrast, then delete the profile.
The code
The same program in five languages (also on GitHub, with the raw output). Set these environment variables first:
CDPFLEET_API_KEY— your API key (dashboard → API keys)PROXY_URL— your proxy, e.g.http://user:[email protected]:8000PROXY_URL_US— a proxy exit in the United States
// npm install [email protected]
// env: CDPFLEET_API_KEY, PROXY_URL_US
import { createHash, randomBytes } from 'node:crypto';
import { firefox } from 'playwright';
const KEY = process.env.CDPFLEET_API_KEY;
const NAME = `case49-node-${randomBytes(4).toString('hex')}`; // one profile per run
const SITE = 'https://httpbin.org/html';
// One country for every launch: Camoufox matches timezone and locale to the exit IP.
const BODY = { proxy: process.env.PROXY_URL_US, headless: true, os: 'windows', record: false };
async function api(method, path) {
const res = await fetch(`https://cdpfleet.com${path}`, { method, headers: { 'x-api-key': KEY } });
return { status: res.status, body: await res.json().catch(() => ({})) };
}
// The node saves the profile after the session's browser has exited: wait for the new version.
async function waitForVersion(version) {
for (let i = 0; i < 60; i++) {
const p = (await api('GET', '/v1/me/profiles')).body.profiles?.find((x) => x.name === NAME);
if (p?.version >= version && !p.in_use) return p;
await new Promise((r) => setTimeout(r, 2000));
}
throw new Error(`profile ${NAME} never reached version ${version}`);
}
// 503 means the fleet is momentarily busy: wait Retry-After and try again.
async function launch(body) {
for (let attempt = 1; ; attempt++) {
const res = await fetch('https://starter.cdpfleet.com/camoufox/session', {
method: 'POST',
headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
body: JSON.stringify(body),
});
if (res.ok) return res.json();
if (res.status !== 503 || 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));
}
}
// Proxies drop a tunnel now and then: up to two retries.
async function go(page, url) {
for (let attempt = 1; ; attempt++) {
try {
return await page.goto(url, { timeout: 60000 });
} catch (err) {
if (attempt === 3) throw err;
await new Promise((r) => setTimeout(r, 2000));
}
}
}
// What a fingerprinting script reads, plus the "login" kept in a cookie and localStorage.
async function visit(page, { logIn }) {
await go(page, SITE);
if (logIn) await page.evaluate(() => { document.cookie = 'session=alice-7f3; max-age=604800; path=/'; localStorage.setItem('user', 'alice'); });
const fp = await page.evaluate(() => {
const gl = document.createElement('canvas').getContext('webgl');
const dbg = gl && gl.getExtension('WEBGL_debug_renderer_info');
const draw = () => {
const c = document.createElement('canvas');
c.width = 240; c.height = 60;
const ctx = c.getContext('2d');
ctx.font = '18px Arial'; ctx.fillStyle = '#f60'; ctx.fillRect(10, 10, 100, 30); ctx.fillStyle = '#069'; ctx.fillText('cdpfleet device 49', 4, 40);
return c.toDataURL();
};
return {
user_agent: navigator.userAgent,
platform: navigator.platform,
cores: navigator.hardwareConcurrency,
screen: `${screen.width}x${screen.height}`,
webgl_renderer: dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : null,
canvas: draw(),
canvas_again: draw(), // the same drawing twice in one session
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
languages: navigator.languages,
login_cookie: document.cookie.includes('session=alice-7f3'),
login_storage: localStorage.getItem('user') === 'alice',
};
});
const hash = (x) => createHash('sha256').update(x).digest('hex').slice(0, 12);
fp.canvas_same_in_session = fp.canvas === fp.canvas_again;
fp.canvas = hash(fp.canvas);
delete fp.canvas_again;
return fp;
}
async function session(body, logIn) {
const s = await launch(body);
const browser = await firefox.connect(s.wsUrl, { headers: { 'x-api-key': KEY } });
try {
// With a profile, its context is browser.contexts()[0]; without one, a fresh context.
const context = body.profile ? browser.contexts()[0] : await browser.newContext();
const page = context.pages()[0] ?? await context.newPage();
return await visit(page, { logIn });
} finally {
await browser.close();
}
}
// The device's hardware-like values (user agent, platform, cores, screen, GPU); the canvas is
// reported on its own.
const device = (r) => createHash('sha256').update(JSON.stringify([r.user_agent, r.platform, r.cores, r.screen, r.webgl_renderer])).digest('hex').slice(0, 10);
try {
// Four launches with one profile: the first creates it (device + login), the rest restore it.
const withProfile = [];
for (let run = 1; run <= 4; run++) {
withProfile.push({ run, ...(await session({ ...BODY, profile: NAME }, run === 1)) });
await waitForVersion(run);
}
// Two launches without a profile, for contrast.
const without = [];
for (let run = 1; run <= 2; run++) without.push({ run, ...(await session(BODY, false)) });
const pick = (r) => ({ run: r.run, device: device(r), user_agent: r.user_agent, cores: r.cores, screen: r.screen, webgl_renderer: r.webgl_renderer, canvas: r.canvas, canvas_same_in_session: r.canvas_same_in_session, timezone: r.timezone, login_cookie: r.login_cookie, login_storage: r.login_storage });
console.log(JSON.stringify({ with_profile: withProfile.map(pick), without_profile: without.map(pick) }, null, 2));
} finally {
await api('DELETE', `/v1/me/profiles/${NAME}`);
}
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL_US
import hashlib
import json
import os
import secrets
import time
import requests
from playwright.sync_api import sync_playwright
KEY = os.environ["CDPFLEET_API_KEY"]
NAME = f"case49-python-{secrets.token_hex(4)}" # one profile per run
SITE = "https://httpbin.org/html"
# One country for every launch: Camoufox matches timezone and locale to the exit IP.
BODY = {"proxy": os.environ["PROXY_URL_US"], "headless": True, "os": "windows", "record": False}
FINGERPRINT = """() => {
const gl = document.createElement('canvas').getContext('webgl');
const dbg = gl && gl.getExtension('WEBGL_debug_renderer_info');
const draw = () => {
const c = document.createElement('canvas');
c.width = 240; c.height = 60;
const ctx = c.getContext('2d');
ctx.font = '18px Arial'; ctx.fillStyle = '#f60'; ctx.fillRect(10, 10, 100, 30); ctx.fillStyle = '#069'; ctx.fillText('cdpfleet device 49', 4, 40);
return c.toDataURL();
};
return {
user_agent: navigator.userAgent,
platform: navigator.platform,
cores: navigator.hardwareConcurrency,
screen: `${screen.width}x${screen.height}`,
webgl_renderer: dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : null,
canvas: draw(),
canvas_again: draw(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
languages: navigator.languages,
login_cookie: document.cookie.includes('session=alice-7f3'),
login_storage: localStorage.getItem('user') === 'alice',
};
}"""
def short_hash(text, n):
return hashlib.sha256(text.encode()).hexdigest()[:n]
def api(method, path):
return requests.request(method, f"https://cdpfleet.com{path}", headers={"x-api-key": KEY}, timeout=30)
# The node saves the profile after the session's browser has exited: wait for the new version.
def wait_for_version(version):
for _ in range(60):
p = next((x for x in api("GET", "/v1/me/profiles").json().get("profiles", []) if x["name"] == NAME), None)
if p and p["version"] >= version and not p["in_use"]:
return p
time.sleep(2)
raise RuntimeError(f"profile {NAME} never reached version {version}")
# 503 means the fleet is momentarily busy: wait Retry-After and try again.
def launch(body):
for attempt in range(1, 6):
r = requests.post("https://starter.cdpfleet.com/camoufox/session", headers={"x-api-key": KEY}, json=body, timeout=120)
if r.ok:
return r.json()
if r.status_code != 503 or attempt == 5:
raise RuntimeError(f"launch {r.status_code} {r.text}")
time.sleep(int(r.headers.get("retry-after") or 5))
# Proxies drop a tunnel now and then: up to two retries.
def go(page, url):
for attempt in range(1, 4):
try:
return page.goto(url, timeout=60000)
except Exception:
if attempt == 3:
raise
time.sleep(2)
# What a fingerprinting script reads, plus the "login" kept in a cookie and localStorage.
def visit(page, log_in):
go(page, SITE)
if log_in:
page.evaluate("() => { document.cookie = 'session=alice-7f3; max-age=604800; path=/'; localStorage.setItem('user', 'alice'); }")
fp = page.evaluate(FINGERPRINT)
fp["canvas_same_in_session"] = fp["canvas"] == fp["canvas_again"]
fp["canvas"] = short_hash(fp["canvas"], 12)
del fp["canvas_again"]
return fp
def session(pw, body, log_in):
s = launch(body)
browser = pw.firefox.connect(s["wsUrl"], headers={"x-api-key": KEY})
try:
# With a profile, its context is browser.contexts[0]; without one, a fresh context.
context = browser.contexts[0] if "profile" in body else browser.new_context()
page = context.pages[0] if context.pages else context.new_page()
return visit(page, log_in)
finally:
browser.close()
# The device's hardware-like values (user agent, platform, cores, screen, GPU); the canvas is
# reported on its own. JSON like JavaScript's JSON.stringify, so the hash matches across languages.
def device(r):
return short_hash(json.dumps([r["user_agent"], r["platform"], r["cores"], r["screen"], r["webgl_renderer"]], separators=(",", ":"), ensure_ascii=False), 10)
def pick(run, r):
return {"run": run, "device": device(r), "user_agent": r["user_agent"], "cores": r["cores"], "screen": r["screen"],
"webgl_renderer": r["webgl_renderer"], "canvas": r["canvas"], "canvas_same_in_session": r["canvas_same_in_session"],
"timezone": r["timezone"], "login_cookie": r["login_cookie"], "login_storage": r["login_storage"]}
try:
with sync_playwright() as pw:
# Four launches with one profile: the first creates it (device + login), the rest restore it.
with_profile = []
for run in range(1, 5):
with_profile.append(pick(run, session(pw, {**BODY, "profile": NAME}, run == 1)))
wait_for_version(run)
# Two launches without a profile, for contrast.
without = [pick(run, session(pw, BODY, False)) for run in range(1, 3)]
print(json.dumps({"with_profile": with_profile, "without_profile": without}, indent=2, ensure_ascii=False))
finally:
api("DELETE", f"/v1/me/profiles/{NAME}")
// 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_US
import com.google.gson.*;
import com.microsoft.playwright.*;
import java.net.URI;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
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();
static final String NAME = "case49-java-" + HexFormat.of().formatHex(new SecureRandom().generateSeed(4)); // one profile per run
static final String SITE = "https://httpbin.org/html";
static final String FINGERPRINT = "() => {"
+ "const gl = document.createElement('canvas').getContext('webgl');"
+ "const dbg = gl && gl.getExtension('WEBGL_debug_renderer_info');"
+ "const draw = () => { const c = document.createElement('canvas'); c.width = 240; c.height = 60; const ctx = c.getContext('2d');"
+ " ctx.font = '18px Arial'; ctx.fillStyle = '#f60'; ctx.fillRect(10, 10, 100, 30); ctx.fillStyle = '#069'; ctx.fillText('cdpfleet device 49', 4, 40); return c.toDataURL(); };"
+ "return { user_agent: navigator.userAgent, platform: navigator.platform, cores: navigator.hardwareConcurrency, screen: `${screen.width}x${screen.height}`,"
+ " webgl_renderer: dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : null, canvas: draw(), canvas_again: draw(),"
+ " timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, languages: navigator.languages,"
+ " login_cookie: document.cookie.includes('session=alice-7f3'), login_storage: localStorage.getItem('user') === 'alice' }; }";
static String shortHash(String text, int n) throws Exception {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8))).substring(0, n);
}
static HttpResponse<String> api(String method, String path) throws Exception {
return HTTP.send(HttpRequest.newBuilder(URI.create("https://cdpfleet.com" + path)).header("x-api-key", KEY).method(method, HttpRequest.BodyPublishers.noBody()).build(), HttpResponse.BodyHandlers.ofString());
}
// The node saves the profile after the session's browser has exited: wait for the new version.
static void waitForVersion(int version) throws Exception {
for (int i = 0; i < 60; i++) {
for (JsonElement e : JsonParser.parseString(api("GET", "/v1/me/profiles").body()).getAsJsonObject().getAsJsonArray("profiles")) {
JsonObject p = e.getAsJsonObject();
if (p.get("name").getAsString().equals(NAME) && p.get("version").getAsInt() >= version && !p.get("in_use").getAsBoolean()) return;
}
Thread.sleep(2000);
}
throw new RuntimeException("profile " + NAME + " never reached version " + version);
}
// 503 means the fleet is momentarily busy: wait Retry-After and try again.
static JsonObject launch(JsonObject body) throws Exception {
for (int attempt = 1; ; attempt++) {
HttpResponse<String> res = HTTP.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(body.toString())).build(), HttpResponse.BodyHandlers.ofString());
if (res.statusCode() == 200) return JsonParser.parseString(res.body()).getAsJsonObject();
if (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);
}
}
// Proxies drop a tunnel now and then: up to two retries.
static void go(Page page, String url) throws Exception {
for (int attempt = 1; ; attempt++) {
try {
page.navigate(url, new Page.NavigateOptions().setTimeout(60000));
return;
} catch (PlaywrightException err) {
if (attempt == 3) throw err;
Thread.sleep(2000);
}
}
}
// What a fingerprinting script reads, plus the "login" kept in a cookie and localStorage.
static JsonObject visit(Page page, boolean logIn) throws Exception {
go(page, SITE);
if (logIn) page.evaluate("() => { document.cookie = 'session=alice-7f3; max-age=604800; path=/'; localStorage.setItem('user', 'alice'); }");
JsonObject fp = GSON.toJsonTree(page.evaluate(FINGERPRINT)).getAsJsonObject();
String canvas = fp.get("canvas").getAsString();
fp.addProperty("canvas_same_in_session", canvas.equals(fp.get("canvas_again").getAsString()));
fp.addProperty("canvas", shortHash(canvas, 12));
fp.remove("canvas_again");
return fp;
}
static JsonObject session(Playwright pw, JsonObject body, boolean logIn) throws Exception {
JsonObject s = launch(body);
Browser browser = pw.firefox().connect(s.get("wsUrl").getAsString(), new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
try {
// With a profile, its context is browser.contexts().get(0); without one, a fresh context.
BrowserContext context = body.has("profile") ? browser.contexts().get(0) : browser.newContext();
Page page = context.pages().isEmpty() ? context.newPage() : context.pages().get(0);
return visit(page, logIn);
} finally {
browser.close();
}
}
// The device's hardware-like values (user agent, platform, cores, screen, GPU); the canvas is
// reported on its own. Compact JSON like JavaScript's JSON.stringify, so hashes match.
static String device(JsonObject r) throws Exception {
JsonArray a = new JsonArray();
a.add(r.get("user_agent")); a.add(r.get("platform")); a.add(r.get("cores").getAsInt()); a.add(r.get("screen")); a.add(r.get("webgl_renderer"));
return shortHash(new GsonBuilder().disableHtmlEscaping().serializeNulls().create().toJson(a), 10);
}
static JsonObject pick(int run, JsonObject r) throws Exception {
JsonObject o = new JsonObject();
o.addProperty("run", run);
o.addProperty("device", device(r));
for (String k : new String[] {"user_agent", "cores", "screen", "webgl_renderer", "canvas", "canvas_same_in_session", "timezone", "login_cookie", "login_storage"}) o.add(k, r.get(k));
o.addProperty("cores", r.get("cores").getAsInt());
return o;
}
public static void main(String[] args) throws Exception {
JsonObject base = new JsonObject();
base.addProperty("proxy", System.getenv("PROXY_URL_US")); // one country: Camoufox matches timezone and locale to the exit IP
base.addProperty("headless", true);
base.addProperty("os", "windows");
base.addProperty("record", false);
try (Playwright pw = Playwright.create()) {
// Four launches with one profile: the first creates it (device + login), the rest restore it.
JsonArray withProfile = new JsonArray();
for (int run = 1; run <= 4; run++) {
JsonObject body = base.deepCopy();
body.addProperty("profile", NAME);
withProfile.add(pick(run, session(pw, body, run == 1)));
waitForVersion(run);
}
// Two launches without a profile, for contrast.
JsonArray without = new JsonArray();
for (int run = 1; run <= 2; run++) without.add(pick(run, session(pw, base.deepCopy(), false)));
JsonObject out = new JsonObject();
out.add("with_profile", withProfile);
out.add("without_profile", without);
System.out.println(GSON.toJson(out));
} finally {
api("DELETE", "/v1/me/profiles/" + NAME);
}
}
}
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL_US
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
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 name = $"case49-csharp-{Convert.ToHexString(RandomNumberGenerator.GetBytes(4)).ToLower()}"; // one profile per run
const string Site = "https://httpbin.org/html";
// One country for every launch: Camoufox matches timezone and locale to the exit IP.
JsonObject Body() => new() { ["proxy"] = Environment.GetEnvironmentVariable("PROXY_URL_US"), ["headless"] = true, ["os"] = "windows", ["record"] = false };
const string Fingerprint = @"() => {
const gl = document.createElement('canvas').getContext('webgl');
const dbg = gl && gl.getExtension('WEBGL_debug_renderer_info');
const draw = () => {
const c = document.createElement('canvas');
c.width = 240; c.height = 60;
const ctx = c.getContext('2d');
ctx.font = '18px Arial'; ctx.fillStyle = '#f60'; ctx.fillRect(10, 10, 100, 30); ctx.fillStyle = '#069'; ctx.fillText('cdpfleet device 49', 4, 40);
return c.toDataURL();
};
return {
user_agent: navigator.userAgent, platform: navigator.platform, cores: navigator.hardwareConcurrency,
screen: `${screen.width}x${screen.height}`, webgl_renderer: dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : null,
canvas: draw(), canvas_again: draw(), timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, languages: navigator.languages,
login_cookie: document.cookie.includes('session=alice-7f3'), login_storage: localStorage.getItem('user') === 'alice',
};
}";
string ShortHash(string text, int n) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text))).ToLower()[..n];
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
using var playwright = await Playwright.CreateAsync();
// The node saves the profile after the session's browser has exited: wait for the new version.
async Task WaitForVersion(int version)
{
for (var i = 0; i < 60; i++)
{
var profiles = (await http.GetFromJsonAsync<JsonElement>("https://cdpfleet.com/v1/me/profiles")).GetProperty("profiles");
foreach (var p in profiles.EnumerateArray())
if (p.GetProperty("name").GetString() == name && p.GetProperty("version").GetInt32() >= version && !p.GetProperty("in_use").GetBoolean()) return;
await Task.Delay(2000);
}
throw new Exception($"profile {name} never reached version {version}");
}
// 503 means the fleet is momentarily busy: wait Retry-After and try again.
async Task<JsonElement> Launch(JsonObject body)
{
for (var attempt = 1; ; attempt++)
{
var res = await http.PostAsync("https://starter.cdpfleet.com/camoufox/session", new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"));
if (res.IsSuccessStatusCode) return await res.Content.ReadFromJsonAsync<JsonElement>();
if ((int)res.StatusCode != 503 || attempt == 5) throw new Exception($"launch {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");
await Task.Delay((int)(res.Headers.RetryAfter?.Delta?.TotalMilliseconds ?? 5000));
}
}
// Proxies drop a tunnel now and then: up to two retries.
async Task Go(IPage page, string url)
{
for (var attempt = 1; ; attempt++)
{
try { await page.GotoAsync(url, new() { Timeout = 60000 }); return; }
catch (PlaywrightException) when (attempt < 3) { await Task.Delay(2000); }
}
}
// What a fingerprinting script reads, plus the "login" kept in a cookie and localStorage.
async Task<JsonObject> Visit(IPage page, bool logIn)
{
await Go(page, Site);
if (logIn) await page.EvaluateAsync("() => { document.cookie = 'session=alice-7f3; max-age=604800; path=/'; localStorage.setItem('user', 'alice'); }");
var fp = JsonNode.Parse((await page.EvaluateAsync<JsonElement>(Fingerprint)).GetRawText())!.AsObject();
var canvas = fp["canvas"]!.GetValue<string>();
fp["canvas_same_in_session"] = canvas == fp["canvas_again"]!.GetValue<string>();
fp["canvas"] = ShortHash(canvas, 12);
fp.Remove("canvas_again");
return fp;
}
async Task<JsonObject> Session(JsonObject body, bool logIn)
{
var s = await Launch(body);
var browser = await playwright.Firefox.ConnectAsync(s.GetProperty("wsUrl").GetString()!, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
try
{
// With a profile, its context is browser.Contexts[0]; without one, a fresh context.
var context = body.ContainsKey("profile") ? browser.Contexts[0] : await browser.NewContextAsync();
var page = context.Pages.Count > 0 ? context.Pages[0] : await context.NewPageAsync();
return await Visit(page, logIn);
}
finally
{
await browser.CloseAsync();
}
}
// The device's hardware-like values (user agent, platform, cores, screen, GPU); the canvas is
// reported on its own. Compact JSON like JavaScript's JSON.stringify, so hashes match.
string Device(JsonObject r) => ShortHash(new JsonArray(r["user_agent"]!.DeepClone(), r["platform"]!.DeepClone(), r["cores"]!.DeepClone(), r["screen"]!.DeepClone(), r["webgl_renderer"]?.DeepClone())
.ToJsonString(new JsonSerializerOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }), 10);
JsonObject Pick(int run, JsonObject r)
{
var o = new JsonObject { ["run"] = run, ["device"] = Device(r) };
foreach (var k in new[] { "user_agent", "cores", "screen", "webgl_renderer", "canvas", "canvas_same_in_session", "timezone", "login_cookie", "login_storage" }) o[k] = r[k]?.DeepClone();
return o;
}
try
{
// Four launches with one profile: the first creates it (device + login), the rest restore it.
var withProfile = new JsonArray();
for (var run = 1; run <= 4; run++)
{
var body = Body();
body["profile"] = name;
withProfile.Add(Pick(run, await Session(body, run == 1)));
await WaitForVersion(run);
}
// Two launches without a profile, for contrast.
var without = new JsonArray();
for (var run = 1; run <= 2; run++) without.Add(Pick(run, await Session(Body(), false)));
Console.WriteLine(new JsonObject { ["with_profile"] = withProfile, ["without_profile"] = without }.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
}
finally
{
await http.DeleteAsync($"https://cdpfleet.com/v1/me/profiles/{name}");
}
// go get github.com/playwright-community/[email protected]
// Driver: build playwright-core 1.60.0 from npm and set PLAYWRIGHT_DRIVER_PATH (see /docs/quickstart).
// env: CDPFLEET_API_KEY, PROXY_URL_US
package main
import (
"bytes"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/playwright-community/playwright-go"
)
var key = os.Getenv("CDPFLEET_API_KEY")
const site = "https://httpbin.org/html"
const fingerprint = `() => {
const gl = document.createElement('canvas').getContext('webgl');
const dbg = gl && gl.getExtension('WEBGL_debug_renderer_info');
const draw = () => {
const c = document.createElement('canvas');
c.width = 240; c.height = 60;
const ctx = c.getContext('2d');
ctx.font = '18px Arial'; ctx.fillStyle = '#f60'; ctx.fillRect(10, 10, 100, 30); ctx.fillStyle = '#069'; ctx.fillText('cdpfleet device 49', 4, 40);
return c.toDataURL();
};
return {
user_agent: navigator.userAgent, platform: navigator.platform, cores: navigator.hardwareConcurrency,
screen: screen.width + 'x' + screen.height, webgl_renderer: dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : null,
canvas: draw(), canvas_again: draw(), timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
login_cookie: document.cookie.includes('session=alice-7f3'), login_storage: localStorage.getItem('user') === 'alice',
};
}`
func shortHash(text string, n int) string {
h := sha256.Sum256([]byte(text))
return hex.EncodeToString(h[:])[:n]
}
func api(method, path string) (*http.Response, error) {
req, _ := http.NewRequest(method, "https://cdpfleet.com"+path, nil)
req.Header.Set("x-api-key", key)
return http.DefaultClient.Do(req)
}
// The node saves the profile after the session's browser has exited: wait for the new version.
func waitForVersion(name string, version int) error {
for i := 0; i < 60; i++ {
res, err := api("GET", "/v1/me/profiles")
if err == nil {
var body struct {
Profiles []struct {
Name string `json:"name"`
Version int `json:"version"`
InUse bool `json:"in_use"`
} `json:"profiles"`
}
json.NewDecoder(res.Body).Decode(&body)
res.Body.Close()
for _, p := range body.Profiles {
if p.Name == name && p.Version >= version && !p.InUse {
return nil
}
}
}
time.Sleep(2 * time.Second)
}
return fmt.Errorf("profile %s never reached version %d", name, version)
}
// 503 means the fleet is momentarily busy: wait Retry-After and try again.
func launch(opts map[string]any) (map[string]any, error) {
body, _ := json.Marshal(opts)
for attempt := 1; ; attempt++ {
req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/camoufox/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
}
b, _ := io.ReadAll(res.Body)
res.Body.Close()
if res.StatusCode == 200 {
var session map[string]any
return session, json.Unmarshal(b, &session)
}
if res.StatusCode != 503 || attempt == 5 {
return nil, fmt.Errorf("launch %d %s", res.StatusCode, strings.TrimSpace(string(b)))
}
wait, err := strconv.Atoi(res.Header.Get("retry-after"))
if err != nil {
wait = 5
}
time.Sleep(time.Duration(wait) * time.Second)
}
}
// Proxies drop a tunnel now and then: up to two retries.
func goTo(page playwright.Page, url string) error {
for attempt := 1; ; attempt++ {
_, err := page.Goto(url, playwright.PageGotoOptions{Timeout: playwright.Float(60000)})
if err == nil || attempt == 3 {
return err
}
time.Sleep(2 * time.Second)
}
}
type fp struct {
UserAgent string `json:"user_agent"`
Platform string `json:"platform"`
Cores int `json:"cores"`
Screen string `json:"screen"`
WebGLRenderer *string `json:"webgl_renderer"`
Canvas string `json:"canvas"`
CanvasAgain string `json:"canvas_again"`
Timezone string `json:"timezone"`
LoginCookie bool `json:"login_cookie"`
LoginStorage bool `json:"login_storage"`
}
// What a fingerprinting script reads, plus the "login" kept in a cookie and localStorage.
func visit(page playwright.Page, logIn bool) (fp, error) {
var out fp
if err := goTo(page, site); err != nil {
return out, err
}
if logIn {
if _, err := page.Evaluate("() => { document.cookie = 'session=alice-7f3; max-age=604800; path=/'; localStorage.setItem('user', 'alice'); }"); err != nil {
return out, err
}
}
raw, err := page.Evaluate(fingerprint)
if err != nil {
return out, err
}
b, _ := json.Marshal(raw)
return out, json.Unmarshal(b, &out)
}
func session(pw *playwright.Playwright, opts map[string]any, logIn bool) (fp, error) {
s, err := launch(opts)
if err != nil {
return fp{}, err
}
browser, err := pw.Firefox.Connect(s["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
if err != nil {
return fp{}, err
}
defer browser.Close()
// With a profile, its context is browser.Contexts()[0]; without one, a fresh context.
var context playwright.BrowserContext
if _, ok := opts["profile"]; ok {
context = browser.Contexts()[0]
} else if context, err = browser.NewContext(); err != nil {
return fp{}, err
}
var page playwright.Page
if pages := context.Pages(); len(pages) > 0 {
page = pages[0]
} else if page, err = context.NewPage(); err != nil {
return fp{}, err
}
return visit(page, logIn)
}
// Field order matches the other languages' output.
type row struct {
Run int `json:"run"`
Device string `json:"device"`
UserAgent string `json:"user_agent"`
Cores int `json:"cores"`
Screen string `json:"screen"`
WebGLRenderer *string `json:"webgl_renderer"`
Canvas string `json:"canvas"`
CanvasSameInSession bool `json:"canvas_same_in_session"`
Timezone string `json:"timezone"`
LoginCookie bool `json:"login_cookie"`
LoginStorage bool `json:"login_storage"`
}
// The device's hardware-like values (user agent, platform, cores, screen, GPU); the canvas is
// reported on its own. Compact JSON like JavaScript's JSON.stringify, so hashes match.
func pick(run int, f fp) row {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.Encode([]any{f.UserAgent, f.Platform, f.Cores, f.Screen, f.WebGLRenderer})
return row{run, shortHash(strings.TrimSpace(buf.String()), 10), f.UserAgent, f.Cores, f.Screen, f.WebGLRenderer,
shortHash(f.Canvas, 12), f.Canvas == f.CanvasAgain, f.Timezone, f.LoginCookie, f.LoginStorage}
}
func run() error {
rnd := make([]byte, 4)
rand.Read(rnd)
name := "case49-go-" + hex.EncodeToString(rnd) // one profile per run
defer api("DELETE", "/v1/me/profiles/"+name)
// One country for every launch: Camoufox matches timezone and locale to the exit IP.
base := func() map[string]any {
return map[string]any{"proxy": os.Getenv("PROXY_URL_US"), "headless": true, "os": "windows", "record": false}
}
pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
if err != nil {
return err
}
defer pw.Stop()
// Four launches with one profile: the first creates it (device + login), the rest restore it.
withProfile := []row{}
for i := 1; i <= 4; i++ {
opts := base()
opts["profile"] = name
f, err := session(pw, opts, i == 1)
if err != nil {
return err
}
withProfile = append(withProfile, pick(i, f))
if err := waitForVersion(name, i); err != nil {
return err
}
}
// Two launches without a profile, for contrast.
without := []row{}
for i := 1; i <= 2; i++ {
f, err := session(pw, base(), false)
if err != nil {
return err
}
without = append(without, pick(i, f))
}
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if err := enc.Encode(map[string]any{"with_profile": withProfile, "without_profile": without}); err != nil {
return err
}
fmt.Print(buf.String())
return nil
}
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}
What we got
| Launch | Device hash | Screen · cores · GPU | Canvas hash (same twice in session?) | Timezone | Login kept |
|---|---|---|---|---|---|
| profile, 1 | 9b4c5e3d22 | 2176x1224 · 24 · ANGLE (Intel, Intel(R) HD Graphics Direct3D11 vs_5_0 ps_5_0), or similar | 2aa3626347ab (yes) | America/New_York | set |
| profile, 2 | 9b4c5e3d22 | 2176x1224 · 24 · ANGLE (Intel, Intel(R) HD Graphics Direct3D11 vs_5_0 ps_5_0), or similar | eee3aa52d041 (yes) | America/Chicago | yes |
| profile, 3 | 9b4c5e3d22 | 2176x1224 · 24 · ANGLE (Intel, Intel(R) HD Graphics Direct3D11 vs_5_0 ps_5_0), or similar | 3406714b0218 (yes) | America/Chicago | yes |
| profile, 4 | 9b4c5e3d22 | 2176x1224 · 24 · ANGLE (Intel, Intel(R) HD Graphics Direct3D11 vs_5_0 ps_5_0), or similar | edfb4c52ec36 (yes) | America/New_York | yes |
| no profile, 1 | e57979ea7d | 2176x1224 · 8 · ANGLE (NVIDIA, NVIDIA GeForce GTX 980 Direct3D11 vs_5_0 ps_5_0), or similar | 3f49470cb734 (yes) | America/New_York | — |
| no profile, 2 | de4852f49f | 1920x1080 · 8 · ANGLE (NVIDIA, NVIDIA GeForce GTX 980 Direct3D11 vs_5_0 ps_5_0), or similar | 42bb3f9ae210 (yes) | America/New_York | — |
From the Node.js run on 2026-10-15. 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)
{
"with_profile": [
{
"run": 1,
"device": "9b4c5e3d22",
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0",
"cores": 24,
"screen": "2176x1224",
"webgl_renderer": "ANGLE (Intel, Intel(R) HD Graphics Direct3D11 vs_5_0 ps_5_0), or similar",
"canvas": "2aa3626347ab",
"canvas_same_in_session": true,
"timezone": "America/New_York",
"login_cookie": true,
"login_storage": true
},
{
"run": 2,
"device": "9b4c5e3d22",
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0",
"cores": 24,
"screen": "2176x1224",
"webgl_renderer": "ANGLE (Intel, Intel(R) HD Graphics Direct3D11 vs_5_0 ps_5_0), or similar",
"canvas": "eee3aa52d041",
"canvas_same_in_session": true,
"timezone": "America/Chicago",
"login_cookie": true,
"login_storage": true
},
{
"run": 3,
"device": "9b4c5e3d22",
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0",
"cores": 24,
"screen": "2176x1224",
"webgl_renderer": "ANGLE (Intel, Intel(R) HD Graphics Direct3D11 vs_5_0 ps_5_0), or similar",
"canvas": "3406714b0218",
"canvas_same_in_session": true,
"timezone": "America/Chicago",
"login_cookie": true,
"login_storage": true
},
{
"run": 4,
"device": "9b4c5e3d22",
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0",
"cores": 24,
"screen": "2176x1224",
"webgl_renderer": "ANGLE (Intel, Intel(R) HD Graphics Direct3D11 vs_5_0 ps_5_0), or similar",
"canvas": "edfb4c52ec36",
"canvas_same_in_session": true,
"timezone": "America/New_York",
"login_cookie": true,
"login_storage": true
}
],
"without_profile": [
{
"run": 1,
"device": "e57979ea7d",
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0",
"cores": 8,
"screen": "2176x1224",
"webgl_renderer": "ANGLE (NVIDIA, NVIDIA GeForce GTX 980 Direct3D11 vs_5_0 ps_5_0), or similar",
"canvas": "3f49470cb734",
"canvas_same_in_session": true,
"timezone": "America/New_York",
"login_cookie": false,
"login_storage": false
},
{
"run": 2,
"device": "de4852f49f",
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0",
"cores": 8,
"screen": "1920x1080",
"webgl_renderer": "ANGLE (NVIDIA, NVIDIA GeForce GTX 980 Direct3D11 vs_5_0 ps_5_0), or similar",
"canvas": "42bb3f9ae210",
"canvas_same_in_session": true,
"timezone": "America/New_York",
"login_cookie": false,
"login_storage": false
}
]
}Takeaways
- The device comes back: all four launches with the profile reported the same user agent, platform, CPU cores, screen and WebGL renderer — one device hash. Launches without a profile drew a new persona each time — from a limited set of common real configurations, so while building this case one unrelated launch happened to match the profile's screen, cores and GPU exactly. A profile is what makes the same device certain.
- So does the login: the cookie and localStorage value set on the first launch were there on the second, third and fourth.
- The canvas changes on every launch, even with the profile: the same drawing gave the same hash twice within a session, but a different one in each new session. Camoufox re-seeds its canvas noise per launch, so a site that stores a canvas hash sees it change between visits — judge for yourself whether your target compares canvases across sessions.
- Timezone follows the exit, not the profile: a US exit landed in different states, so the timezone moved between US time zones from one launch to the next. Use a sticky exit — a residential
-session-token or a fixed proxy — when the account should also stay in one city. - Profiles are on paid plans, 100 per account; see Persistent profiles and the Camoufox profile recipe.