iplogs.com
Guide · Last updated 2026-05-24

Client-side VPN detection signals (and their limits)

What the browser actually exposes about a VPN user — and what it doesn't. WebRTC ICE candidates, TLS JA3/JA4, timezone vs IP geo, plus the pitfalls (mDNS obfuscation, fingerprinting mitigations, dishonest user-agents). Client-side signals are useful as an input to a graded verdict; they are not, on their own, a substitute for server-side multi-source detection.

What a page can collect from the browser

Four families of client-side signal are worth measuring:

  • WebRTC ICE candidates. The browser's peer-connection APIs gather candidate addresses via STUN. A server-reflexive candidate can reveal a public IP that differs from the HTTP source IP — a strong signal that one of the two paths is being relayed.
  • TLS fingerprint (JA3/JA4). Hashed server-side from the ClientHello. A mismatch between a claimed user-agent and the TLS fingerprint exposes spoofing, VPN clients, and headless automation. See the JA3/JA4 glossary entry for context.
  • Timezone and language. Intl.DateTimeFormat() .resolvedOptions().timeZone and navigator.language reflect the OS, not the network. A US-East timezone with a Frankfurt IP is a useful prior — soft, but cheap.
  • RTT and timing. PerformanceResourceTiming exposes TCP and TLS handshake durations for fresh connections. Anomalies between transport and TLS RTT are part of the SNITCH paper's detection approach (NDSS 2025 — check the published paper) and are a server-side read more than a client one.

WebRTC ICE candidate gathering

The classic “WebRTC leak”: open an RTCPeerConnection, add a no-op data channel, and harvest the candidates the browser collects. The interesting ones are server-reflexive (typ srflx) — public IPs as seen by a STUN server. If that public IP differs from the IP your HTTP server saw, something between them is relaying.

// Best-effort STUN-based public-IP discovery, ~1.5s bounded.
function getWebRtcPublicIp(): Promise<string | undefined> {
  return new Promise((resolve) => {
    if (!("RTCPeerConnection" in window)) return resolve(undefined);
    const pc = new RTCPeerConnection({
      iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
    });
    pc.createDataChannel("probe");
    const timer = setTimeout(() => { pc.close(); resolve(undefined); }, 1500);
    pc.onicecandidate = (e) => {
      if (!e.candidate) return;
      const parts = e.candidate.candidate.split(" ");
      const type = parts[parts.indexOf("typ") + 1];
      const ip = parts[4];
      if (type === "srflx" && /^[0-9.]+$/.test(ip)) {
        clearTimeout(timer);
        pc.close();
        resolve(ip);
      }
    };
    pc.createOffer().then((o) => pc.setLocalDescription(o)).catch(() => {
      clearTimeout(timer); resolve(undefined);
    });
  });
}

What the snippet won't catch: browsers that have moved to mDNS hostnames for local candidates, browsers behind strict privacy modes that don't emit srflxat all, and STUN servers that don't reply. Treat a non-result as “unknown,” not “clean.”

TLS JA3/JA4 fingerprinting

JA3 and JA4 are fingerprint formats over the TLS ClientHello. Two clients claiming to be the same browser version will share a fingerprint if the claim is honest; a mismatch is a signal of spoofing or an interposed VPN/proxy client. JA4 (the newer variant) hashes more fields and is harder to collide.

The fingerprint is captured on the server side from the TLS handshake — there is no JavaScript API for it in the browser. On Caddy, fronting the application with a TLS terminator that extracts the ClientHello, or running the JA3 computation in a reverse-proxy module, is the practical route. The IPLogs API does this for you and surfaces the result as a signal in /v1/check.

Timezone, language, and screen geometry

Cheap, collectable from any page, and only ever a prior:

function collectClientSignals() {
  return {
    timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, // e.g. "Europe/Berlin"
    language: navigator.language,                                 // e.g. "en-US"
    screen:   `${screen.width}x${screen.height}`,             // e.g. "1920x1080"
    platform: navigator.platform,                                 // e.g. "MacIntel"
  };
}

Compare the OS timezone against the country your IP geolocates to. The two agree for the vast majority of real users. The disagreements split into three buckets: travellers (allow), users on a VPN to a different country (your detection's job), and people who have changed their OS timezone manually (rare). Combined with a datacenter ASN, a strong mismatch is a useful raise on the risk score.

Send client signals alongside the IPLogs check

The IPLogs API accepts optional client-side fields and combines them with server-side detection so you don't have to merge verdicts yourself:

const body = {
  user_agent: navigator.userAgent,
  timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
  language: navigator.language,
  webrtc_ip: await getWebRtcPublicIp(),  // may be undefined
};

const r = await fetch("https://iplogs.com/v1/check", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify(body),
});
const verdict = await r.json();

The server adds the request's source IP automatically, runs the seven-layer pipeline, and folds the client signals into the score where they apply. A WebRTC IP that differs from the HTTP source IP turns into a dedicated signal in the response.

Why client-side alone isn't enough

  • User-agents lie. A custom client or a VPN browser extension can present any user-agent. JA3/JA4 catches it only because the TLS layer betrays the lie.
  • Browsers actively limit fingerprinting. Safari has restricted timezone exposure for years; Firefox has a privacy.resistFingerprinting mode; Chrome has been quietly narrowing the WebRTC IP surface. Any single client signal can be missing or randomised.
  • JavaScript can be off. Crawlers, accessibility tools, and a non-trivial number of security-aware users disable JS. A detection layer that depends on JS misses both attackers and legitimate visitors.

Combining client signals with the server verdict

The practical pattern: the server-side verdict (IPLogs /v1/checkon the visitor IP) is your floor. Client-side data adds confidence and helps catch edge cases (Apple Private Relay vs commercial VPN, browser-vs-OS mismatch). For the longer write-up on why a single-signal block doesn't work, see why complete VPN blocking can't be a compliance solution.

FAQ

Can JavaScript see the user's real IP behind a VPN?

Sometimes, via WebRTC ICE candidate gathering. STUN-discovered server-reflexive candidates can reveal a public IP that differs from the HTTP source IP. Modern browsers obfuscate local IPs with mDNS hostnames and several limit public-IP exposure, so this signal is partial and degrading over time. Treat it as advisory, never as ground truth.

What is a JA3 or JA4 fingerprint, and where does it come from?

JA3 and JA4 are hashes of the TLS ClientHello a client sends when starting an HTTPS connection. The hash is computed server-side from the connection, not in the browser. A user-agent claiming to be Chrome with a TLS fingerprint that doesn't match Chrome is a strong sign of a VPN client, automation framework, or proxy in the path.

Why is timezone vs IP geolocation a useful signal?

A browser exposes the OS timezone through Intl.DateTimeFormat. If the timezone resolves to America/Los_Angeles and the IP geolocates to Frankfurt, something is rewriting one side of the path. It's not a smoking gun (travellers exist) but it raises the prior, especially combined with a datacenter ASN or a known VPN exit.

Are client-side signals enough on their own?

No. Each one is defeatable in isolation, and modern browsers actively reduce the information a page can collect. The honest pattern is: use the server-side verdict from /v1/check as the primary signal, then let client-side data raise or lower the score. Client signals are advisory; the server-side multi-source verdict is authoritative.

Won't users see the WebRTC permission prompt?

No. STUN-based IP discovery via RTCPeerConnection doesn't require user permission — only access to a camera or microphone does. The candidate-gathering surface is what privacy patches in modern browsers have been quietly narrowing for years, which is exactly why this signal degrades.

For the server-side counterpart, see the implementation walkthrough, the WebRTC-leak glossary entry, and the accuracy benchmark. Try it now in your browser: live IP checker.