A browser reports Europe/London while an IP database places the request in America/New_York. That is a disagreement between two observations, and it is worth recording. It is not proof that the visitor uses a VPN, and treating it as proof is where most implementations go wrong. The comparison below keeps the two values, and their failure modes, separate.

Two observations with different owners

The browser value is a setting: the zone the operating system or user chose for formatting dates. The database value is an estimate attached to an IP network, produced by a provider that maps address blocks to places. Neither is an authenticated statement about where the visitor is standing, and neither knows which network path the request took.

Because the owners differ, the two values can diverge for reasons that have nothing to do with privacy tooling. A traveller who keeps the home zone, a company laptop pinned to headquarters time, a satellite link with a regional egress, or a stale database row all produce a mismatch.

How each value is actually produced

A browser can read its resolved zone with new Intl.DateTimeFormat().resolvedOptions().timeZone, which returns an IANA identifier derived from the formatter settings. It does not request a location permission and it does not consult GPS. On the server, resolve the client address from your trusted proxy configuration, then look it up with an approved provider. A forwarding header supplied by the visitor is not automatically that address.

Store the two observations as separate fields with the observation time and the lookup outcome. Naming the derived field vpnDetected when the calculation is only a zone comparison bakes a conclusion into a measurement and makes later review harder.

What agreement and disagreement each prove

ObservationWhat it leaves unresolved
Different reported zonesTravel, a retained setting, a business gateway, a proxy or a database error can all produce it.
The same reported zoneA VPN exit can sit in the user zone, so agreement does not establish a direct connection.
One value missing or invalidThe comparison is unavailable, which is neither a match nor a mismatch.

The asymmetry matters more than the table suggests. A mismatch has many innocent explanations, while a match has at least one guilty one, so neither direction supports a categorical rule. The provider's own accuracy notes describe limited precision and missing data, and they point out that an anonymizing service can expose its server location rather than the user.

That is why the signal belongs in a risk score with a recorded reason and an explicit unknown state, not in a boolean that gates access.

UTC offsets are not a shortcut

Comparing offsets instead of zone identifiers does not repair the inference. Two different zones can share an offset for part of the year, and the same zone can change offset at a daylight-saving boundary. If a feature genuinely needs an offset for scheduling, compute it for the specific instant rather than storing one number and reusing it as identity evidence.

String comparison has its own trap: two spellings of the same zone, or a legacy alias, will differ as text while describing the same setting. Validate the identifiers and compare through the platform time-zone handling instead of matching raw strings.

Block or score: choose the response deliberately

For a time display, ask the visitor which zone they prefer and stop there. For an application risk system, treat a mismatch as one weak contextual signal alongside evidence that fits the actual threat model, and let a failed lookup produce an unknown rather than a silent detection. Blocking on the comparison alone converts a measurement gap into a user-facing accusation.

If a consequential decision needs stronger assurance, use an authentication or verification step designed for it. A time-zone heuristic cannot supply that assurance, and a detector that counts missing evidence as success can look effective while answering a different question than the one you asked.

Artihkkalat mat laktásit