liarjs.dev
← Field notes
Geolocation

Timezone vs IP: the cross-layer check that catches proxies

September 5, 2026 · 9 min read

Timezone is the one identity signal a browser cannot keep to itself. The exit IP announces a rough geography to every server it touches. The browser clock announces another to every page it loads. When those two disagree, the detector needs no lie-detector and no entropy model. It needs a subtraction. This is the cheapest strong tell in the book, and in controlled testing two well-funded anti-detect browsers failed it live.

Timezone following the exit IP versus a static/broken geoip that pins a US IP to a foreign clock

Why the check is so strong

Most fingerprinting signals are noisy. Canvas hashes drift across driver versions. Font lists vary by install. GPU strings never sit still. Timezone behaves differently. It is a cross-layer assertion, and the two layers come from independent parts of your stack that a spoofer has to keep manually in sync.

  • The network layer hands the server your exit IP. A geoip lookup maps that IP to an IANA zone like America/Los_Angeles. Cloudflare does this at the edge and exposes it as cf-timezone / request.cf.timezone.
  • The browser layer reports its own zone through Intl.DateTimeFormat().resolvedOptions().timeZone and its offset through Date.getTimezoneOffset().

Nobody legitimately runs a US residential line while their OS clock insists it is in Singapore. So a detector that compares the IP-derived zone to the browser-reported zone gets a high-value signal for the price of one string comparison. Whoer surfaces it on its front page as part of the anonymity verdict. Incolumitas folds Intl against IP into its bot score alongside TCP/IP and proxy-header checks. Every serious commercial anti-fraud vendor runs some version of it server-side, where you cannot see it.

The check has a second thing going for it. It is hard to fake correctly and easy to fake wrong. Getting it right means the spoofed timezone must track the current proxy exit on every rotation, across every surface the browser exposes. Getting it wrong means shipping a static profile value and hoping the proxy never moves. That is the trap both failures below fell into.

The cross-layer check, concretely

The scanner reads the edge’s IP-derived timezone from the request and the browser’s Intl zone from the page, then compares:

// client-side, in the page
const intlZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
// e.g. "Asia/Singapore"

// server-side, from Cloudflare's edge geoip on the exit IP
// request.cf.timezone -> "America/Los_Angeles"
// verdict: compare the two zones directly
if (ipTimezone && intlZone && ipTimezone !== intlZone) {
  // WARN: IP resolves to <ipTimezone> but browser reports <intlZone>
}

A note on weighting, because this is where naive implementations overreach. A genuine traveler on a foreign SIM, a corporate VPN user, anyone deliberately routing through another region: all of them legitimately show IP not equal to timezone. It is a real configuration, not proof of automation. So the scanner treats a bare mismatch as a warn rather than an instant fail, a signal that turns damning only when it stacks with other tells (empty navigator.languages, a canvas that changes between reads, a stripped plugin face). Hard-block on this alone and you flag real humans. Ignore it and you leave free signal on the table. Weight it.

Two real failures

We ran controlled competitor testing against the same US residential proxy pool and watched two products fail the check in opposite ways. Both failures share one root cause.

CloakBrowser, a source-level Chromium fork driven by --fingerprint-* flags, was run with its automatic geoip timezone feature enabled. That setting’s entire job is to derive the zone from the proxy exit. With the exit IP sitting in the US, it reported:

Intl.DateTimeFormat().resolvedOptions().timeZone  ->  "Asia/Singapore"

A US IP with a Singapore clock. Any check comparing IP against timezone catches that instantly. The automatic feature had failed silently and pinned the wrong hemisphere. Only a manual --fingerprint-timezone=America/Los_Angeles aligned the clock to the exit.

In controlled anti-detect browser testing, the automatic timezone feature failed silently and pinned a US proxy to a Singapore clock. The one setting meant to hide you announced that you were spoofed.

VMLogin failed more subtly, and in a way, more instructively. Its profile carried a static timezone of America/Chicago while the current exit IP resolved to America/Los_Angeles:

Product Exit IP zone Browser Intl zone Delta Verdict
CloakBrowser (auto-geoip) America/Los_Angeles Asia/Singapore ~15 h, wrong continent mismatch, instant
VMLogin (static profile) America/Los_Angeles America/Chicago ~2 zones, same country mismatch, still caught
CloakBrowser (manual --fingerprint-timezone) America/Los_Angeles America/Los_Angeles 0 aligned

Same country, roughly two zones off. Lighter than the Singapore blunder but still a mismatch. Pacific and Central differ by a full hour of offset and carry a different IANA identifier, so the subtraction still comes out non-zero.

The root cause in both cases is identical: the timezone was a static profile value, not something derived from the current proxy exit IP. CloakBrowser tried to derive it automatically and the derivation broke with no fallback. VMLogin never tried. It trusted a value baked into the profile at creation time, which the proxy quietly outgrew.

The internal check: Intl must agree with Date

The comparison of IP against timezone needs the network. There is a second timezone check that needs nothing but JavaScript, and it catches a different class of sloppiness: the browser lying to itself.

Intl.DateTimeFormat().resolvedOptions().timeZone returns an IANA name. Date.prototype.getTimezoneOffset() returns the offset in minutes (positive when behind UTC). These two must be self-consistent. Say Intl claims America/Los_Angeles, which is UTC-7 in summer and UTC-8 in winter, but getTimezoneOffset() returns a number implying UTC-5. Now the browser has been half-spoofed. Somebody overrode the Intl string without overriding the Date math, or the reverse.

The scanner does not hardcode offsets, because they move with DST. Instead it recomputes the expected offset for the IANA zone on the current date, using Intl itself with timeZoneName: 'longOffset', and compares to getTimezoneOffset() within a one-minute tolerance:

// Expected UTC offset (minutes, sign matching getTimezoneOffset:
// positive = behind UTC) for an IANA zone, on the current date.
function tzOffsetMinutes(tz) {
  const parts = new Intl.DateTimeFormat('en-US', {
    timeZone: tz,
    timeZoneName: 'longOffset',
  }).formatToParts(new Date());
  const name = parts.find((p) => p.type === 'timeZoneName')?.value || '';
  const m = name.match(/GMT([+-])(\d{1,2})(?::(\d{2}))?/);
  if (!m) return name.includes('GMT') ? 0 : null;
  const sign = m[1] === '-' ? 1 : -1;          // "GMT-07:00" => +420 min behind UTC
  return sign * (parseInt(m[2], 10) * 60 + (m[3] ? parseInt(m[3], 10) : 0));
}

// compare the derived value against what the runtime reports
const expected = tzOffsetMinutes(Intl.DateTimeFormat().resolvedOptions().timeZone);
const actual = new Date().getTimezoneOffset();
if (expected !== null && Math.abs(expected - actual) > 1) {
  // BAD: the zone name and the offset disagree, a spoof seam
}

DST is the whole reason for the recompute. Anyone can hardcode America/Los_Angeles → -480. But on 2026-07-15 the correct answer is -420, because the zone is on daylight time. A spoofer who pins a fixed offset gets it right for half the year and wrong for the other half, while the check recomputes against today every time it runs. timeZoneName: 'longOffset' gives you the resolved, DST-aware offset for the actual date. Do not shortcut it with a static table.

Full coverage: the clock must follow the exit everywhere

Passing the two checks above is necessary but not sufficient. Timezone and locale leak from more than Intl on the main thread, and a spoof that patches one surface while missing another produces the same seam a detector is looking for. Coherent coverage means the override reaches, at minimum:

  • Main thread Intl and Date.
  • Web Workers. A worker gets its own JS context. Intl.DateTimeFormat().resolvedOptions().timeZone inside a worker must match the main thread. CreepJS explicitly cross-checks worker against main and flags divergence.
  • Accept-Language header. Its primary tag must match navigator.languages[0]. A Accept-Language: en-US header on a browser reporting fr-FR is the locale analog of the timezone mismatch.
  • navigator.languages should include a bare base tag. Real Chrome ships ["en-US", "en"]. CloakBrowser shipped ["en-US"], missing the bare "en", a small but real deviation from genuine Chrome that a matcher can key on.

And all of it must follow the proxy exit, not sit frozen in a profile. This is the discipline VMLogin lacked and CloakBrowser’s automation botched. If you build auto-geoip, it needs two things the CloakBrowser failure shows are non-negotiable. First, a fallback for when the lookup fails. Second, a self-consistency check so a bad result gets caught before it ships to the page instead of silently pinning you to Singapore. Auto-geoip without a self-check is worse than a sane static default, because it fails in high-confidence, wrong directions.

One more locale tell rides alongside timezone: speechSynthesis. The default voice’s lang can leak the host OS language even when Intl is fully spoofed. A persona claiming en-US whose default TTS voice reports zh-CN has announced its real Windows install, a voiceLangMismatch. It loads asynchronously, so short probes often miss it and a full run catches it. Do not assume a clean 5-second scan means the voice surface is aligned.

Here is the full timezone/locale surface the scanner tracks and what a coherent US-persona spoof should present:

Surface Signal Coherent value (US persona) Failure mode observed
Network → geoip IP-derived zone America/Los_Angeles none
Intl (main) resolvedOptions().timeZone America/Los_Angeles CloakBrowser → Asia/Singapore
Date getTimezoneOffset() -420 (Jul), -480 (Jan) offset ≠ zone if half-patched
Web Worker worker Intl zone matches main worker/main divergence
HTTP Accept-Language en-US,en;q=… primary tag ≠ languages[0]
navigator.languages array ["en-US", "en"] CloakBrowser → ["en-US"]
speechSynthesis default voice lang en-US host-OS leak → zh-CN

Takeaway

Timezone against IP is cheap, cross-layer, and hard to fake right, which is why it stays valuable while flashier signals decay. Get the internal offset between Intl and Date self-consistent on the current date. Then make every locale surface follow the current exit IP: main thread, worker, Accept-Language, navigator.languages, even the TTS voice, with a fallback and a self-check if you automate it. Treat a bare mismatch as a warn rather than a hard block, because real travelers exist. On a bot it rarely travels alone. CloakBrowser’s silent auto-geoip failure is the whole lesson in one line: the setting built to hide you is the one that announced you were spoofed.

Sources & further reading
  1. Whoer.net
  2. bot.incolumitas.com
  3. CreepJS (Abraham Juliot)
More field notes
← Back to field notes