cdpfleet Docs GitHub Dashboard

Cases / #74 · 2026-10-28 · Medium

Does the browser agree with its IP? Timezone, language and WebRTC through exits in three countries

Five setups through residential exits in the US, Germany and Japan: which ones keep timezone, language, Accept-Language, Intl formats and the WebRTC address consistent with the exit IP, and which mismatches a site can check.

Camoufox Google Chrome

Run on production on 2026-10-28: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go

The problem

A site that knows your IP can compare it with what the browser says: is the browser's timezone the one the IP is in, does its language fit the country, does Accept-Language say the same as navigator.language, and does WebRTC reveal another address? Any disagreement is a cheap signal that something is off. This case measures five setups against those checks, through one sticky residential exit per country. See what a page can read about your browser for the signals in general.

What we used, and why

WhatWhy
cdpfleet-resi-country-us|de|jp-session-…One sticky residential exit per country, shared by that country's five setups.
geoipCamoufox matches timezone, language and location to the exit IP at launch (on by default).
localeCamoufox's language and formats, set by hand (here a deliberate mismatch, pt-BR).
browser.newContext({ locale, timezoneId })For Chrome: set language and timezone per context, here from a first lookup of the exit.
main_world_eval + "mw:"Reads the values in the page's own JavaScript world on Camoufox, as the site sees them.

How it works

  1. For each country, open one sticky residential exit and run the five setups one after another on it.
  2. Open the exit-lookup page (ip-api.com's JSON, which carries the exit's country, timezone and IP).
  3. In that page, read navigator.languages, the Accept-Language the browser sends (echoed by httpbin.org), Intl's timezone and locale, a formatted number, and the WebRTC public address, compared with the exit IP and never printed.
  4. List each check that disagrees with the exit.

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 (residential exits come from cdpfleet-resi tokens)
import { chromium, firefox } from 'playwright';

const KEY = process.env.CDPFLEET_API_KEY;
const RUN = Date.now().toString(36).slice(-4);
const COUNTRIES = [
  { country: 'US', locale: 'en-US' },
  { country: 'DE', locale: 'de-DE' },
  { country: 'JP', locale: 'ja-JP' },
];
// [label, engine, launch options, context options from the exit ("match" = exit's locale + timezone)]
const SETUPS = [
  ['Camoufox (geoip on, default)', 'camoufox', { geoip: true }],
  ['Camoufox, geoip: false', 'camoufox', { geoip: false }],
  ['Camoufox, locale: "pt-BR"', 'camoufox', { geoip: true, locale: 'pt-BR' }],
  ['Chrome headful, no locale', 'chrome', { headless: false }],
  ['Chrome headful, context locale + timezoneId from the exit', 'chrome', { headless: false }, 'match'],
];

// What a site can read and compare, run on the exit-lookup page itself (ip-api.com's JSON, so the
// page knows the exit): exit country and timezone, languages, Accept-Language (echoed by httpbin),
// Intl's timezone and locale, a formatted number, and the WebRTC public address (compared with the
// exit, never returned).
const CHECK = `(async () => {
  const exit = JSON.parse(document.body.innerText);
  const headers = await fetch('https://httpbin.org/headers').then((r) => r.json()).catch(() => ({ headers: {} }));
  const srflx = await new Promise((done) => {
    const ips = new Set();
    try {
      const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
      pc.createDataChannel('x');
      pc.onicecandidate = (e) => { const c = e.candidate && e.candidate.candidate; if (c && c.includes(' srflx ')) ips.add(c.split(' ')[4]); };
      pc.createOffer().then((o) => pc.setLocalDescription(o));
      setTimeout(() => { pc.close(); done([...ips]); }, 4000);
    } catch { done([]); }
  });
  const intl = Intl.DateTimeFormat().resolvedOptions();
  const acceptLanguage = (headers.headers['Accept-Language'] || '').split(',')[0];
  const out = {
    exit_country: exit.countryCode, exit_timezone: exit.timezone,
    languages: navigator.languages.join(', '), accept_language: acceptLanguage,
    timezone: intl.timeZone, intl_locale: intl.locale, number: (1234567.891).toLocaleString(),
    webrtc: srflx.length === 0 ? 'none' : srflx.every((ip) => ip === exit.query) ? 'exit IP' : 'another IP',
  };
  out.mismatches = [
    out.timezone !== exit.timezone && 'timezone ≠ IP timezone',
    (navigator.language.split('-')[1] || '').toUpperCase() !== exit.countryCode && 'language region ≠ IP country',
    acceptLanguage.toLowerCase() !== navigator.language.toLowerCase() && 'Accept-Language ≠ navigator.language',
    !intl.locale.toLowerCase().startsWith(navigator.language.slice(0, 2).toLowerCase()) && 'Intl locale ≠ navigator.language',
    out.webrtc === 'another IP' && 'WebRTC shows another IP',
  ].filter(Boolean);
  return out;
})()`;

async function launch(engine, body) {
  const res = await fetch(`https://starter.cdpfleet.com/${engine}/session`, {
    method: 'POST',
    headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`launch ${res.status} ${(await res.text()).slice(0, 120)}`);
  return res.json();
}

async function measure({ country, locale }, [label, engine, options, contextFrom], i) {
  const proxy = `cdpfleet-resi-country-${country.toLowerCase()}-session-c74${country.toLowerCase()}${RUN}-lifetime-10`;
  const row = { setup: label, country };
  try {
    const camoufox = engine === 'camoufox';
    const { wsUrl } = await launch(engine, { proxy, headless: camoufox, record: false, ...(camoufox ? { main_world_eval: true } : {}), ...options });
    const browser = await (camoufox ? firefox : chromium).connect(wsUrl, { headers: { 'x-api-key': KEY } });
    try {
      let contextOptions = {};
      if (contextFrom === 'match') { // look the exit up first, then set the context to match it
        const probe = await browser.newContext();
        const p = await probe.newPage();
        await p.goto('http://ip-api.com/json/?fields=timezone', { timeout: 60000 });
        contextOptions = { locale, timezoneId: JSON.parse(await p.textContent('body')).timezone };
        await probe.close();
      }
      const context = await browser.newContext(contextOptions);
      const page = await context.newPage();
      await page.goto('http://ip-api.com/json/?fields=countryCode,timezone,query', { timeout: 60000 });
      Object.assign(row, await page.evaluate(camoufox ? `mw:${CHECK}` : CHECK));
    } finally {
      await browser.close();
    }
  } catch (err) {
    row.error = err.message.split('\n')[0];
  }
  return row;
}

// Countries in parallel, setups one after another within a country (one exit per country).
const results = await Promise.all(COUNTRIES.map(async (c) => {
  const rows = [];
  for (const [i, s] of SETUPS.entries()) rows.push(await measure(c, s, i));
  return rows;
}));
console.log(JSON.stringify(results.flat(), null, 2));

What we got

SetupExitBrowser timezoneIP timezonenavigator.languagesAccept-LanguageNumberWebRTCMismatches a site can check
Camoufox (geoip on, default)USAmerica/New_YorkAmerica/New_Yorken-US, enen-US1,234,567.891exit IPnone
Camoufox, geoip: falseUSUTCAmerica/New_Yorken-US, enen-US1,234,567.891nonetimezone ≠ IP timezone
Camoufox, locale: "pt-BR"USAmerica/New_YorkAmerica/New_Yorkpt-BR, ptpt-BR1.234.567,891exit IPlanguage region ≠ IP country
Chrome headful, no localeUSUTCAmerica/New_Yorken-US, enen-US1,234,567.891nonetimezone ≠ IP timezone
Chrome headful, context locale + timezoneId from the exitUSAmerica/New_YorkAmerica/New_Yorken-USen-US1,234,567.891nonenone
Camoufox (geoip on, default)DEEurope/BerlinEurope/Berlinde-DE, dede-DE1.234.567,891exit IPnone
Camoufox, geoip: falseDEUTCEurope/Berlinen-US, enen-US1,234,567.891nonetimezone ≠ IP timezone; language region ≠ IP country
Camoufox, locale: "pt-BR"DEEurope/BerlinEurope/Berlinpt-BR, ptpt-BR1.234.567,891exit IPlanguage region ≠ IP country
Chrome headful, no localeDEUTCEurope/Berlinen-US, enen-US1,234,567.891nonetimezone ≠ IP timezone; language region ≠ IP country
Chrome headful, context locale + timezoneId from the exitDEEurope/BerlinEurope/Berlinde-DEde-DE1.234.567,891nonenone
Camoufox (geoip on, default)JPAsia/TokyoAsia/Tokyoja-JP, jaja-JP1,234,567.891exit IPnone
Camoufox, geoip: falseJPUTCAsia/Tokyoen-US, enen-US1,234,567.891nonetimezone ≠ IP timezone; language region ≠ IP country
Camoufox, locale: "pt-BR"JPAsia/TokyoAsia/Tokyopt-BR, ptpt-BR1.234.567,891exit IPlanguage region ≠ IP country
Chrome headful, no localeJPUTCAsia/Tokyoen-US, enen-US1,234,567.891nonetimezone ≠ IP timezone; language region ≠ IP country
Chrome headful, context locale + timezoneId from the exitJPAsia/TokyoAsia/Tokyoja-JPja-JP1,234,567.891nonenone

From the Node.js run on 2026-10-28. 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)
[
  {
    "setup": "Camoufox (geoip on, default)",
    "country": "US",
    "exit_country": "US",
    "exit_timezone": "America/New_York",
    "languages": "en-US, en",
    "accept_language": "en-US",
    "timezone": "America/New_York",
    "intl_locale": "en-US",
    "number": "1,234,567.891",
    "webrtc": "exit IP",
    "mismatches": []
  },
  {
    "setup": "Camoufox, geoip: false",
    "country": "US",
    "exit_country": "US",
    "exit_timezone": "America/New_York",
    "languages": "en-US, en",
    "accept_language": "en-US",
    "timezone": "UTC",
    "intl_locale": "en-US",
    "number": "1,234,567.891",
    "webrtc": "none",
    "mismatches": [
      "timezone ≠ IP timezone"
    ]
  },
  {
    "setup": "Camoufox, locale: \"pt-BR\"",
    "country": "US",
    "exit_country": "US",
    "exit_timezone": "America/New_York",
    "languages": "pt-BR, pt",
    "accept_language": "pt-BR",
    "timezone": "America/New_York",
    "intl_locale": "pt-BR",
    "number": "1.234.567,891",
    "webrtc": "exit IP",
    "mismatches": [
      "language region ≠ IP country"
    ]
  },
  {
    "setup": "Chrome headful, no locale",
    "country": "US",
    "exit_country": "US",
    "exit_timezone": "America/New_York",
    "languages": "en-US, en",
    "accept_language": "en-US",
    "timezone": "UTC",
    "intl_locale": "en-US",
    "number": "1,234,567.891",
    "webrtc": "none",
    "mismatches": [
      "timezone ≠ IP timezone"
    ]
  },
  {
    "setup": "Chrome headful, context locale + timezoneId from the exit",
    "country": "US",
    "exit_country": "US",
    "exit_timezone": "America/New_York",
    "languages": "en-US",
    "accept_language": "en-US",
    "timezone": "America/New_York",
    "intl_locale": "en-US",
    "number": "1,234,567.891",
    "webrtc": "none",
    "mismatches": []
  },
  {
    "setup": "Camoufox (geoip on, default)",
    "country": "DE",
    "exit_country": "DE",
    "exit_timezone": "Europe/Berlin",
    "languages": "de-DE, de",
    "accept_language": "de-DE",
    "timezone": "Europe/Berlin",
    "intl_locale": "de-DE",
    "number": "1.234.567,891",
    "webrtc": "exit IP",
    "mismatches": []
  },
  {
    "setup": "Camoufox, geoip: false",
    "country": "DE",
    "exit_country": "DE",
    "exit_timezone": "Europe/Berlin",
    "languages": "en-US, en",
    "accept_language": "en-US",
    "timezone": "UTC",
    "intl_locale": "en-US",
    "number": "1,234,567.891",
    "webrtc": "none",
    "mismatches": [
      "timezone ≠ IP timezone",
      "language region ≠ IP country"
    ]
  },
  {
    "setup": "Camoufox, locale: \"pt-BR\"",
    "country": "DE",
    "exit_country": "DE",
    "exit_timezone": "Europe/Berlin",
    "languages": "pt-BR, pt",
    "accept_language": "pt-BR",
    "timezone": "Europe/Berlin",
    "intl_locale": "pt-BR",
    "number": "1.234.567,891",
    "webrtc": "exit IP",
    "mismatches": [
      "language region ≠ IP country"
    ]
  },
  {
    "setup": "Chrome headful, no locale",
    "country": "DE",
    "exit_country": "DE",
    "exit_timezone": "Europe/Berlin",
    "languages": "en-US, en",
    "accept_language": "en-US",
    "timezone": "UTC",
    "intl_locale": "en-US",
    "number": "1,234,567.891",
    "webrtc": "none",
    "mismatches": [
      "timezone ≠ IP timezone",
      "language region ≠ IP country"
    ]
  },
  {
    "setup": "Chrome headful, context locale + timezoneId from the exit",
    "country": "DE",
    "exit_country": "DE",
    "exit_timezone": "Europe/Berlin",
    "languages": "de-DE",
    "accept_language": "de-DE",
    "timezone": "Europe/Berlin",
    "intl_locale": "de-DE",
    "number": "1.234.567,891",
    "webrtc": "none",
    "mismatches": []
  },
  {
    "setup": "Camoufox (geoip on, default)",
    "country": "JP",
    "exit_country": "JP",
    "exit_timezone": "Asia/Tokyo",
    "languages": "ja-JP, ja",
    "accept_language": "ja-JP",
    "timezone": "Asia/Tokyo",
    "intl_locale": "ja-JP",
    "number": "1,234,567.891",
    "webrtc": "exit IP",
    "mismatches": []
  },
  {
    "setup": "Camoufox, geoip: false",
    "country": "JP",
    "exit_country": "JP",
    "exit_timezone": "Asia/Tokyo",
    "languages": "en-US, en",
    "accept_language": "en-US",
    "timezone": "UTC",
    "intl_locale": "en-US",
    "number": "1,234,567.891",
    "webrtc": "none",
    "mismatches": [
      "timezone ≠ IP timezone",
      "language region ≠ IP country"
    ]
  },
  {
    "setup": "Camoufox, locale: \"pt-BR\"",
    "country": "JP",
    "exit_country": "JP",
    "exit_timezone": "Asia/Tokyo",
    "languages": "pt-BR, pt",
    "accept_language": "pt-BR",
    "timezone": "Asia/Tokyo",
    "intl_locale": "pt-BR",
    "number": "1.234.567,891",
    "webrtc": "exit IP",
    "mismatches": [
      "language region ≠ IP country"
    ]
  },
  {
    "setup": "Chrome headful, no locale",
    "country": "JP",
    "exit_country": "JP",
    "exit_timezone": "Asia/Tokyo",
    "languages": "en-US, en",
    "accept_language": "en-US",
    "timezone": "UTC",
    "intl_locale": "en-US",
    "number": "1,234,567.891",
    "webrtc": "none",
    "mismatches": [
      "timezone ≠ IP timezone",
      "language region ≠ IP country"
    ]
  },
  {
    "setup": "Chrome headful, context locale + timezoneId from the exit",
    "country": "JP",
    "exit_country": "JP",
    "exit_timezone": "Asia/Tokyo",
    "languages": "ja-JP",
    "accept_language": "ja-JP",
    "timezone": "Asia/Tokyo",
    "intl_locale": "ja-JP",
    "number": "1,234,567.891",
    "webrtc": "none",
    "mismatches": []
  }
]

Takeaways