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 andnavigator.languagereflect the OS, not the network. A US-East timezone with a Frankfurt IP is a useful prior — soft, but cheap. - RTT and timing.
PerformanceResourceTimingexposes 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.