Skip to content

Timezone, locale, and the Intl API as a geolocation cross-check

· 19 min read
Copyright: MIT
Monospace wordmark reading timeZone over the strings America/New_York and IP says Frankfurt, with an orange underline

A browser carries a clock, and the clock has an opinion about where it lives. Call Intl.DateTimeFormat().resolvedOptions().timeZone in any tab and you get back a string like America/New_York or Europe/Berlin, picked from the IANA database that ships with the operating system. That string came from the machine’s settings, not from the network. Meanwhile the TCP connection arrived from an IP address that a geolocation database places somewhere on a map. Most of the time those two agree, because most people sit behind the IP their ISP hands them, in the city where they live. When they disagree, something is rewriting one half of the picture and not the other.

That gap is the whole subject of this post. A proxy changes the IP without touching the machine’s clock. A datacenter VM in Virginia keeps its host’s UTC timezone while routing its traffic through a residential exit in Lagos. An anti-detect browser patches Intl to claim Tokyo but forgets that getTimezoneOffset and the Accept-Language header tell a different story. None of these are exotic. They are the default failure mode of nearly every scraping and fraud setup that bolts a proxy onto a browser, and the checks that catch them are cheap, deterministic, and run before a CAPTCHA ever appears. The question is how the cross-check works, where the signal comes from, and why it is so much harder to fake convincingly than it looks.

The sections below go in order. First the timezone APIs themselves, what each returns and the sign convention that trips people up. Then the IANA name versus the raw offset, and the January-versus-July probe that turns a coarse number into a sharp one. Then the locale side: Accept-Language, navigator.languages, and the Intl locale, which are three different surfaces for the same preference and diverge in characteristic ways. Then the cross-check proper, where these signals get read against IP geolocation and against each other. Then how spoofing fails, why patching one API leaves the others contradicting it, and what the whole signal is worth in 2026 against an adversary who knows it is being measured.

The timezone APIs and what each returns

There are two ways a page reads your timezone, and they answer slightly different questions.

The modern one is the Internationalization API. Intl.DateTimeFormat().resolvedOptions() returns an object whose timeZone property is an IANA time zone name: UTC, Europe/Brussels, Asia/Riyadh, America/New_York. The full object also carries locale, calendar, and numberingSystem, and conditionally hourCycle and hour12 when the format involves a time. The timeZone value defaults to the runtime’s configured zone, which on a normal machine is whatever the user set in the OS. This API has been Baseline widely available since September 2017, so a detector can assume it exists and treat its absence as its own signal.

The older one is Date.prototype.getTimezoneOffset(), which returns a number of minutes. The catch, and it catches people writing spoofing code as often as people reading it, is that the sign is inverted relative to intuition. The value is positive when the local zone is behind UTC and negative when it is ahead. New York in winter, at UTC-5, returns 300. A zone at UTC+3 returns -180. UTC+10 returns -600. The number is the difference between the date evaluated as UTC and the same date evaluated locally, which is why the sign runs the way it does. MDN states it plainly: the value is “positive if the local time zone is behind UTC, and negative if the local time zone is ahead of UTC.”

two reads of the same machine clock Intl.DateTimeFormat() .resolvedOptions() .timeZone "America/New_York" IANA name. carries the city, not just the offset. new Date() .getTimezoneOffset() 300 signed minutes. positive means behind UTC. easy to invert. *The two APIs answer different questions. Intl names the zone; getTimezoneOffset gives a signed minute count whose sign is the opposite of what most people expect.*

The reason both matter to a detector is that they have to agree with each other, and a careless spoof breaks the agreement. If Intl says America/New_York then getTimezoneOffset() for a January date must return 300 and for a July date must return 240, because New York observes daylight saving. A patched Intl that returns a New York string while getTimezoneOffset still returns 0 is describing a machine that is simultaneously in New York and at UTC. No real device does that.

The IANA name versus the offset, and the DST probe

The IANA name is the richer of the two signals. An offset of UTC-5 is shared by a long list of zones, but America/New_York, America/Toronto, and America/Bogota are different strings with different daylight-saving behavior even when their winter offset matches. The name pins a region; the offset only pins a meridian. That is why a detector that cares about location reads the IANA string first and uses the offset to confirm it.

The offset earns its keep through a small trick that has been folklore in the fingerprinting world for years: probe two dates, one in each half of the year. Instead of reading the offset for today, which depends on whether DST happens to be active right now, you read getTimezoneOffset() for a fixed January date and a fixed June or July date. Two benefits fall out. The fingerprint stops drifting as the seasons change, because the pair is constant for a given zone. And the relationship between the two numbers tells you whether the machine observes daylight saving at all, and in which hemisphere. A northern-hemisphere DST zone shows a larger offset in January than in July. A southern-hemisphere zone shows the reverse. A zone with no DST shows the same number twice.

two fixed dates, one number each getTimezoneOffset() read at Jan 1 and Jul 1 zone Jan Jul reads as America/New_York 300 240 northern DST Australia/Sydney -660 -600 southern DST Asia/Tokyo -540 -540 no DST the pair is stable year-round and reveals hemisphere plus whether the machine honors daylight saving at all. *The offset for a single date depends on the season. A fixed pair of dates does not, and the relationship between the two numbers encodes hemisphere and DST behavior.*

The DST data itself comes from the tzdata database compiled into the runtime, so getTimezoneOffset respects historical rules for the date you pass it. The behavior is consistent for a given region regardless of when the code runs; only the date argument changes the answer. That property is what makes the probe a reliable fingerprint rather than a moving target. It also means a spoof has to reproduce the right DST rule, not just the right current offset, and the rules are not obvious. Lord Howe Island runs a 30-minute DST shift. Morocco suspends DST during Ramadan. A naive spoof that hardcodes a single offset gets the current moment right and the January-July pair wrong.

This timezone signal is one of the older entries in the fingerprinting canon. The 2010 EFF Panopticlick study, which collected 470,161 browser fingerprints between late January and mid-February 2010 and measured 18.1 bits of total entropy across the distribution, already treated timezone (read then via getTimezoneOffset) as one of its eight features. On its own the field is not high-entropy. A 2023 study of 8,400 US browsers measured the timezone attribute at 2.064 bits, with only 0.2 percent of users holding a unique value. The point of timezone in a modern stack is not uniqueness. It is consistency. A 2-bit field that contradicts the IP is worth far more to a detector than a 10-bit field that agrees with everything.

The locale side: three surfaces for one preference

Language preference leaks through three different channels, and they are supposed to line up. They often do not when something synthetic is in the loop.

The first is the Accept-Language request header, sent on every HTTP request before any script runs. It is an ordered, q-weighted list like en-US,en;q=0.9,fr;q=0.8. The browser builds it from the OS or browser UI language settings, and it is one of the oldest passive fingerprinting surfaces there is. The second is navigator.language, a single string giving the primary preference. The third is navigator.languages, an array giving the ordered list, readable from JavaScript. On a clean browser the array in navigator.languages is the same list, in the same order, that the header advertises. That redundancy is the point. A header is set by the network stack; the JS array is read from the same configuration; if they came from one source they match.

They stop matching in two situations a detector cares about. The first is a hand-built HTTP client that sets an Accept-Language header by hand to look like Chrome but cannot set navigator.languages because it is not running JavaScript at all, which is its own tell, or runs a headless engine whose locale was never configured to match the header it forged. The second is a proxy or middlebox that rewrites the header in flight while the JavaScript array, generated client-side, keeps the original. Either way the header and the array diverge, and the divergence is checkable on the first scripted request by comparing the two.

OS language setting HTTP header Accept-Language: en-US,en;q=0.9 sent before any JS JS navigator languages: ["en-US","en"] read client-side Intl locale locale: "en-US" drives formatting one source feeds all three. when they disagree, one of them was rewritten. *The header, the navigator array, and the Intl locale are three reads of the same underlying language setting. A clean browser keeps them aligned; a forged header or a patched runtime breaks the alignment.*

There is real entropy in the exact serialization, which is why browser vendors are trimming it. The country variant alone splits users: a French-Switzerland preference sends fr-CH ahead of fr, while French-Canada sends fr-CA, and those distinctions are useful to a fingerprinter for the same reason they are useful to a content server. Chrome’s Privacy Sandbox has an explainer for reducing the Accept-Language header to a single preferred language by default, on the reasoning that the full list “contains a lot of entropy about the user that is sent to servers by default” and can be captured passively. The same explainer notes the geolocation tie directly: the top preferred language “has a strong correlation with the rough geolocation.” That correlation is exactly what the cross-check exploits. The header is being thinned for privacy, but as of 2026 the relationship it encodes, language implies a likely region, is still the basis of the locale-versus-IP test.

The Intl locale ties the two halves together. Intl.DateTimeFormat().resolvedOptions().locale reports the BCP 47 tag actually resolved after negotiation, and alongside it numberingSystem and calendar carry regional defaults. A machine set to Saudi Arabic resolves to ar-SA with an islamic-umalqura calendar and arab numbering; a Belgian-Dutch machine resolves to nl-BE with gregory and latn. These are not things a spoof usually thinks to set. Patching the timezone string is the obvious move; matching the calendar system and numbering system to the claimed locale is the step that gets skipped, and the skip is visible.

For the deeper structure of the language headers themselves, the way the q-values and ordering form their own signature independent of the cross-check, see the companion post on the Accept header triad. The navigator surface that exposes languages to script sits in a wider object full of correlated fields, covered in the navigator object as a fingerprint.

The cross-check: timezone and locale against the IP

Now the pieces come together. A detector holds, for a given request, at least four things: the IP address and what a geolocation database says about it, the IANA timezone from Intl, the offset pair from getTimezoneOffset, and the language signals from the header and the navigator array. The cross-check asks whether they tell one coherent story about where this client is.

The IP is the anchor because it is the hardest of the four for the client to choose freely. You can patch every JavaScript API in the browser, but the packets still arrive from wherever your exit node sits, and the geolocation database places that exit on a map with a timezone and a set of likely languages attached. So the test runs outward from the IP. Does the IANA timezone fall in the same region the IP geolocates to? Is the offset within a plausible distance of the IP’s expected offset? Does the primary Accept-Language match a language spoken where the IP lives? A residential IP in Frankfurt with Europe/Berlin, an offset pair of -60/-120, and de-DE at the front of the header is a coherent German user. The same German timezone and de-DE arriving from a datacenter IP in Ashburn, Virginia is a German user routing through a US proxy, and the contradiction is the signal.

The offset difference gets bucketed into severity. A common rule of thumb treats an exact match as clean, a one-to-three-hour gap as a minor mismatch that might be a traveler or a nearby VPN, and a gap of three hours or more as a strong proxy or spoofing indicator, because a gap that large is hard to explain with any honest story. The textbook example, repeated across vendor write-ups, is a US datacenter IP carrying an Asia/Tokyo timezone and an English Accept-Language. That specific shape, US exit, Asian clock, English language, is the most common scraping signature there is, and it is trivially flagged.

the IP is the anchor; everything is checked against it IP geolocation Ashburn, US Intl timeZone: "Asia/Tokyo" -- 13h from IP. critical. getTimezoneOffset pair: -540 / -540 -- agrees with Tokyo, not IP Accept-Language: en-US -- third story, third region three signals, three regions, none of them the IP's. the spread is the verdict. *The detector does not need any single field to be unique. It needs them to disagree. A US exit with a Tokyo clock and US-English language is three regions in one request.*

This runs at the edge, before content loads. The transport-layer geolocation is available from the IP the moment the connection opens, well before the JavaScript that reads Intl even executes, which means a server-side scorer can have the IP’s expected timezone in hand and simply wait for the client’s claimed timezone to arrive and compare. The exact field layout each vendor uses is not public, and what gets weighted how is proprietary, but the inputs are not secret: the IP, the Intl timezone, the offset, and the language signals are all readable by any site, and the logic that compares them is straightforward arithmetic and string matching. The hard part is the geolocation database quality, not the comparison.

Where this fits in a larger scoring stack is covered elsewhere. The same coherence philosophy drives DataDome’s first-request signal collection, and the IP-reputation half of the anchor, how an exit gets classified as residential or datacenter in the first place, is its own subject in residential proxy and ASN detection. The latency-based cousin of this check, measuring round-trip time against the claimed location, lives in the geolocation-versus-latency check.

Why spoofing the timezone is harder than patching one API

The obvious counter is to patch Intl so it returns a timezone matching the proxy. Anti-detect browsers do exactly this. Multilogin, AdsPower, Kameleo and the rest sync the reported timezone to the selected proxy’s geolocation, overriding Intl.DateTimeFormat and getTimezoneOffset so the browser claims to sit where its IP sits. Done correctly, the cross-check against the IP passes. The problem is that “correctly” is a high bar, and most setups clear only part of it.

The first failure is internal consistency between the timezone APIs. If a tool overrides Intl to return America/Los_Angeles but leaves getTimezoneOffset returning the host machine’s real value, the two contradict. A check that computes the expected offset from the IANA name and compares it to what getTimezoneOffset actually returns catches this without ever looking at the IP. Worse, a patch that handles the current offset but not the January-July pair gets the present moment right and the DST behavior wrong, which the two-date probe surfaces.

The second failure is the locale fan-out. Patching the timezone is necessary but not sufficient, because the language signals still have to agree. A browser claiming America/Los_Angeles should plausibly carry an English-language preference and a latn numbering system and a gregory calendar. If the timezone was patched but Accept-Language still leads with the operator’s real zh-CN, the locale-versus-timezone check fails even though the timezone-versus-IP check passed. Synchronizing the timezone is one knob; synchronizing the header, the navigator array, the Intl locale, the calendar, and the numbering system is five more, and a real machine sets all six from one coherent OS configuration.

The third failure is the one that has nothing to do with the values and everything to do with the patch. When a runtime overrides a built-in like Intl.DateTimeFormat, the override leaves traces. The function’s toString may no longer read as native code, a property descriptor on the prototype may differ from a clean browser, or a method may throw in a slightly different way than the real implementation. Tooling like CreepJS is built specifically to find these. Its notion of “lies” is not about whether a value looks plausible but about whether the JavaScript object behaves the way an untampered browser’s object would; a patched Intl that returns a perfect timezone string but whose prototype shows tampering is flagged as a lie regardless of how good the string is. The wider technique of catching these prototype tells is the subject of JavaScript runtime fingerprinting, and the reason stealth patches keep losing this race is laid out in why stealth plugins lose.

There is one more wrinkle that cuts the other way, against the privacy-conscious rather than the bot. Firefox’s privacy.resistFingerprinting historically forced the timezone to UTC to deny the signal. The catch, raised in the Bugzilla thread that eventually reconsidered the behavior, is that spoofing one stable string with another stable string does not buy much privacy and breaks calendars and scheduling tools in the process. A machine reporting UTC while its IP geolocates to a real city is itself anomalous, because almost nobody genuinely lives at UTC with a residential IP. Mozilla moved toward per-feature control, and a 2023 support answer documents disabling the UTC spoof through privacy.fingerprintingProtection.overrides set to +AllTargets,-JSDateTimeUTC. The lesson generalizes. A forced UTC, a patched Tokyo, a real Berlin: only one of these is the unremarkable case, and being unremarkable is the whole game.

What the signal is worth in 2026

The timezone-and-locale cross-check is not a clever trap. It is arithmetic. Read the IP, read the clock, read the language, ask whether they describe one place. That simplicity is exactly why it has aged so well while flashier signals come and go. There is no challenge to solve, no script to defeat, nothing for an adversary to reverse-engineer. The values are all freely readable; the work is in collecting an IP that already agrees with them, and that is an operational problem the bot operator has to solve in the real world with real exit nodes, not a coding problem they can solve in the browser.

The practical consequence is that the check punishes the cheap setup and waves through the expensive one. A scraper that rents a datacenter proxy and drives a default headless browser presents a US clock, a US language, and an Asian or European exit, or some equally incoherent spread, and it is flagged on the first request. A scraper that pairs a residential exit in the target country with a browser whose timezone, locale, calendar, and language were all set to match that country passes the cross-check cleanly, because at that point there is nothing left to contradict. The signal does not separate humans from bots. It separates coherent sessions from incoherent ones, and it makes coherence cost money. The bot that gets through is the one whose operator paid for an IP in the right city and configured the machine to honestly agree with it, which is to say the one that stopped cutting the corner the check was watching.

That is the durable property. Most fingerprinting signals are arms races where the defender adds a probe and the attacker patches the value, over and over, on a tightening cycle. The geolocation cross-check is not really patchable, because the thing it measures, agreement between a freely-chosen clock and an expensively-acquired route, can only be satisfied by making the route match, not by lying about the clock. You can spoof every byte the browser emits. You cannot spoof where your packets came from, and the clock has to agree with that.


Sources & further reading

  • MDN (2024), Intl.DateTimeFormat.prototype.resolvedOptions() — the returned object’s properties (timeZone, locale, calendar, numberingSystem) and the September 2017 Baseline date.
  • MDN (2024), Date.prototype.getTimezoneOffset() — the signed-minutes return value, the inverted sign convention, and how DST and the date argument affect it.
  • Nadia El Mouldi / Castle (2025), How to detect browser time zone using JavaScript — the Intl and offset reads, the January/July DST probe, and cross-checking timezone against IP and language.
  • Scrapfly (2026), Timezone & Intl Detector — a live tool reading timeZone, locale, the offset, and the DST pair, with notes on locale formatting as a regional signal.
  • Chrome Privacy Sandbox / explainers-by-googlers (2024), Reduce Accept-Language — the proposal to send one language by default, the entropy argument, and the language-to-geolocation correlation.
  • Gomez-Boix, Laperdrix, Baudry (2018), Hiding in the Crowd — large-scale measurement of fingerprint attributes including timezone and content language across two million fingerprints.
  • Eckersley / EFF (2010), How Unique Is Your Web Browser? — the original Panopticlick study; 470,161 fingerprints, 18.1 bits of entropy, timezone among the eight measured features.
  • Berke et al. (2024), How Unique is Whose Web Browser? — a US demographic fingerprinting study measuring the timezone attribute at 2.064 bits and languages at 1.73 bits.
  • Mozilla Bugzilla (2017–2022), Make UTC Timezone Spoofing optional under resistFingerprinting — the debate over forcing UTC, why a spoofed stable string is still a fingerprint, and the move to per-feature control.
  • Apify (2024), Fingerprinting techniques — the navigator.language and navigator.languages surfaces and the timezone/timezoneOffset fields in a fingerprint payload.
  • abrahamjuliot (2024), CreepJS — a fingerprinting test suite whose “lies” detection catches patched Intl and timezone APIs by prototype tampering rather than value plausibility.

Further reading