- A browser script cannot reliably prove that every visitor is or is not using a VPN.
- A time zone that disagrees with the IP country is a clue: travellers, roaming phones and company networks produce it too, and a VPN exit in the visitor’s own country does not.
- WebRTC can show a second public address, but browsers now hide local addresses, and a different or missing address has ordinary explanations.
- Collect visit evidence in the browser; keep API credentials, the evaluation call and enforcement on your server.
- Handle incomplete checks separately, and do not treat VPN use alone as proof of fraud.
On this page
- What can browser JavaScript actually see?
- Check 1: does the browser time zone match the IP country?
- Check 2: can WebRTC reveal the visitor’s real IP address?
- What JavaScript cannot see
- Where should the VPN detection check run?
- Should a detected VPN block the customer?
- How do you test it before going live?
JavaScript alone cannot reliably tell whether a visitor uses a VPN. A browser script can collect two useful clues, the device time zone and the addresses WebRTC exposes, but neither proves a VPN, and anything the browser decides can be bypassed. A dependable flow collects evidence in the browser, evaluates it on your server and enforces the result there. Maskbreak is a VPN, proxy and fake-browser detection API built on that pattern: it turns live visit evidence into an allow, review or block recommendation.
This guide is for developers protecting a signup, login or checkout in a JavaScript application. It shows both browser checks with working code, explains why each one flags honest visitors, and then wires up the server-side check. If you only need to inspect an address, start with visit evaluation versus IP lookup; they do not provide the same evidence. For the product itself, see the VPN detection API; for the visitor’s side of the question, see whether websites can tell you are using a VPN.
What can browser JavaScript actually see?
The browser exposes information about its environment and can communicate with your server. It does not give your page a trustworthy list of the visitor’s installed VPN apps or a universal “VPN enabled” flag. A timezone difference, unusual browser configuration or address classification can support an investigation, but each has ordinary explanations too.
| Clue | Why it is insufficient | What to do instead |
|---|---|---|
| Timezone and IP location differ | A device setting or travel can explain the difference. | Consider it alongside the connection and the protected action. |
| The address belongs to a cloud network | Cloud hosting does not establish a specific VPN or proxy service. | Keep hosting classification separate from tunnel evidence. |
| No suspicious browser value appears | A negative observation is not proof that a VPN is absent. | Record which checks completed and which were unavailable. |
Check 1: does the browser time zone match the IP country?
The oldest JavaScript VPN check compares two facts. The browser reports the time zone the device is set to through Intl.DateTimeFormat().resolvedOptions(), and your server knows which country the connecting address belongs to. A visitor in Tallinn using a VPN exit in Frankfurt still reports Europe/Tallinn, while the address geolocates to Germany.
// The IANA time zone the device is set to, e.g. "Europe/Tallinn".
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
// Send it with the form data you already post to your own backend.The comparison belongs on the server, which sees the real connecting address. This example runs behind Cloudflare, which adds the visitor’s country as the CF-IPCountry header when IP geolocation is on; use your own geolocation source elsewhere.
// A few entries for illustration. Build the full table from the IANA tzdb
// file zone1970.tab, which lists the countries that use each zone.
const ZONE_COUNTRIES = {
'Europe/Tallinn': ['EE'],
'Europe/Berlin': ['DE', 'DK', 'NO', 'SE', 'SJ'],
'Europe/London': ['GB', 'GG', 'IM', 'JE'],
'America/New_York': ['US'],
'Asia/Kolkata': ['IN']
};
// Resolve old names first: tzdb's "backward" file links them to current ones.
const ZONE_ALIASES = { 'Asia/Calcutta': 'Asia/Kolkata' };
function timezoneCheck(tz, ipCountry) {
const zone = ZONE_ALIASES[tz] || tz;
const countries = ZONE_COUNTRIES[zone];
// XX = no country data, T1 = Tor (Cloudflare's special codes).
if (!countries || !ipCountry || ipCountry === 'XX' || ipCountry === 'T1') {
return 'unknown';
}
return countries.includes(ipCountry) ? 'consistent' : 'mismatch';
}
// In an Express handler:
// const tzResult = timezoneCheck(req.body.tz, req.get('CF-IPCountry'));The three results matter. unknown is not consistent: a missing zone, an unmapped zone or a Tor address means the check did not run. The zone table is also not one country per zone. The IANA zone1970.tab file maps Europe/Berlin to five countries, so a naive one-to-one map would flag every Danish, Norwegian and Swedish visitor whose device reports that zone.
Why a time zone mismatch flags travellers
A mismatch is common among honest visitors, and a VPN user can avoid it entirely:
- Travellers: a laptop often keeps its home zone abroad, while a phone switches to the local zone.
- Roaming phones: mobile data abroad is often routed through the home operator, so the address stays at home while the device clock moves.
- Company networks: a remote worker on a corporate VPN or office gateway appears wherever that gateway is.
- Manual settings: people set their clock to another zone to follow a team, a market or family.
- Geolocation errors: the IP country is itself an estimate, and it is less certain for mobile and satellite networks.
- The reverse case: a VPN user who picks an exit in their own country produces no mismatch at all.
Treat a mismatch as one reason to look closer, never as a verdict. Maskbreak accepts the same value as the optional tz field. It raises timezone_mismatch only when the connection otherwise looks ordinary, because a known VPN exit is expected to disagree, and it moves an allow to review, never to a block.
Wire it into your own app: a free key returns decision, risk_score and reasons for every visit, 10,000 checks a month on the Free plan, no card.
Get an API keyCheck 2: can WebRTC reveal the visitor’s real IP address?
Sometimes. To set up a peer connection, the browser gathers ICE candidates, the addresses at which it might be reached. When you give it a STUN server, it also learns the public address that server saw, called a server-reflexive (srflx) candidate. If a VPN or proxy carries the visitor’s web traffic but not this UDP traffic, that address can differ from the one your server saw. The snippet below collects the srflx addresses without any camera or microphone access.
// Ask a STUN server which public address it sees for this browser.
// Run your own STUN server: a third-party one receives the visitor's address.
async function webrtcPublicAddresses(stunUrl, timeoutMs = 3000) {
if (typeof RTCPeerConnection !== 'function') {
return { status: 'unavailable', addresses: [] };
}
const pc = new RTCPeerConnection({ iceServers: [{ urls: stunUrl }] });
const addresses = new Set();
pc.onicecandidate = event => {
if (!event.candidate || !event.candidate.candidate) return;
// "candidate:<id> <component> <protocol> <priority> <address> <port> typ <type> ..."
const parts = event.candidate.candidate.split(' ');
if (parts[7] === 'srflx') addresses.add(parts[4]);
};
try {
pc.createDataChannel('probe'); // gives the offer something to negotiate
await pc.setLocalDescription(await pc.createOffer());
await new Promise(resolve => {
const timer = setTimeout(resolve, timeoutMs);
pc.onicegatheringstatechange = () => {
if (pc.iceGatheringState === 'complete') { clearTimeout(timer); resolve(); }
};
});
} finally {
pc.close();
}
return { status: addresses.size ? 'observed' : 'no_candidate', addresses: [...addresses] };
}
// webrtcPublicAddresses('stun:stun.example.com:3478').then(console.log);Read the result carefully. The same address as your server saw means the two paths agree; with a full-tunnel VPN both are the VPN’s address, so it proves nothing. A different address means the routes differ, which can be split tunnelling or a proxy that does not carry UDP, but also an ordinary dual-stack connection reaching your server over IPv6 while STUN answers over IPv4. No candidate means a browser policy, extension, firewall or VPN app blocked it: unknown, not a VPN.
Why browsers now hide local addresses
WebRTC host candidates used to expose the device’s private network address, such as 192.168.1.23, to any page, which made it a fingerprinting and tracking tool. The IETF’s WebRTC address-handling requirements define how much a browser may reveal, and current mainstream browsers replace the private address with a random .local name or leave it out unless the page has camera or microphone permission. MDN documents the privacy implications of candidate addresses. The old “WebRTC leak” trick of reading the local address therefore returns nothing useful today.
Do not make a customer’s ability to sign in depend on exposing another address. An absent candidate should not become “definitely a VPN,” and matching addresses should not become “definitely safe.” Recovering an address the visitor tried to hide is also a privacy decision in its own right: say so in your privacy notice if you do it.
What JavaScript cannot see
Everything above is indirect. A page script has no view of:
- the VPN app, its brand, or the operating system’s routing table;
- whether a connection is tunnelled at all, when the tunnel covers every protocol;
- a residential proxy, whose traffic leaves through a real home connection and whose operator can set the browser’s time zone to match;
- the history of the address: which VPN or proxy service it belongs to and how it has been used;
- anything it can trust. The visitor controls the browser, so scripts can be blocked, values rewritten and your endpoint called without your page.
The address, its classification and its history are server-side facts. That is why the verdict belongs on your server, backed by network intelligence, with the browser contributing evidence rather than a decision.
Where should the VPN detection check run?
Use three stages: collect evidence in the browser, evaluate it on your server, then enforce your application’s policy before the protected action. The browser must not decide whether the request is allowed. Someone can bypass a disabled button or call an endpoint without using your page.
- Collect: load the Maskbreak SDK on the protected page. For a custom form, call
window.Sentinel.collect()after the SDK is ready. Forward itstoken,fingerprintEventIdand optionaltzto your own backend. - Evaluate: your backend sends that evidence to
POST https://maskbreak.com/v1/evaluate. KeepMASKBREAK_API_KEYin its environment or secret store, never frontend code. - Enforce: examine the decision and evidence availability before running the existing signup, authentication or payment handler. Keep authorization, CSRF protection and rate limits in place.
The following is only the server-side evaluation call, not a complete login route. The evidence object must come from your validated request body; your surrounding handler still needs input limits, authentication where appropriate, response validation and a timeout fallback.
const response = await fetch('https://maskbreak.com/v1/evaluate', {
method: 'POST',
signal: AbortSignal.timeout(5000),
headers: {
Authorization: 'Bearer ' + process.env.MASKBREAK_API_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify({
token: evidence.token,
fingerprintEventId: evidence.fingerprintEventId,
tz: evidence.tz
})
});
if (!response.ok) throw new Error('Visit check unavailable');
const result = await response.json();
// Validate result, check availability, then enforce your policy.
// An HTTP 200 alone is not permission to perform the action.A complete Express example
Load the SDK on the page with the protected form. Ordinary form submissions then carry the injected monocle (network) and sentinel_fp (device) fields; a custom fetch submission sends the values from window.Sentinel.collect() instead.
<script async src="https://maskbreak.com/assets/sentinel.js"></script>
<form class="monocle-enriched" method="POST" action="/your-login-endpoint">
<!-- Your existing login inputs stay here.
The SDK adds monocle (network) and sentinel_fp (device). -->
</form>On the server, the fraud gate runs before your existing handler, which still verifies credentials and creates the session. It is the integration guide's handler in enforce mode (the guide's version adds a MASKBREAK_MODE switch that starts in watch mode and only logs): it accepts either field shape, holds review and incomplete evidence, refuses block, and never lets a test or sample response authorize a real action.
// Server-side only. Node.js with fetch and AbortSignal.timeout.
app.use(express.json({ limit: '16kb' }));
app.use(express.urlencoded({ extended: false, limit: '16kb' }));
app.post('/your-login-endpoint', async (req, res, next) => {
const body = req.body || {};
const token = body.token || body.monocle;
const fingerprintEventId = body.fingerprintEventId || body.sentinel_fp;
if (typeof token !== 'string' || !token ||
typeof fingerprintEventId !== 'string' || !fingerprintEventId) {
return res.status(409).json({ error: 'Verification required.' });
}
try {
const response = await fetch('https://maskbreak.com/v1/evaluate', {
method: 'POST',
signal: AbortSignal.timeout(5000),
headers: {
Authorization: 'Bearer ' + process.env.MASKBREAK_API_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify({ token, fingerprintEventId, tz: body.tz || body.sentinel_tz })
});
if (!response.ok) {
const refusal = await response.json().catch(() => null);
// The month's checks are used up: continue unchecked until the 1st
// (00:00 UTC), logged. Every other error holds the login.
if (response.status === 429 && refusal?.code === 'monthly_quota_exceeded') {
console.warn('[maskbreak] monthly_quota_exceeded: continuing unchecked');
return next();
}
throw new Error('Evaluation unavailable');
}
const result = await response.json();
if (!result || !['allow', 'review', 'block'].includes(result.decision) ||
result.test || result.sample || result.sandbox) {
throw new Error('No production verdict');
}
if (result.decision === 'block') {
return res.status(403).json({ error: 'Request declined.' });
}
if (result.decision === 'review' || result.degraded || !result.device) {
return res.status(409).json({ error: 'Verification required.' });
}
return next(); // Fraud gate passed; existing authentication still runs.
} catch {
return res.status(503).json({ error: 'Verification unavailable. Try again later.' });
}
}, existingLoginHandler);For explanations and logs, read network.vpn, network.proxy and network.tor; network.service names the VPN or proxy service when known and may be null. For a full-device assessment, inspect device.complete and device.checked_signals; an unchecked boolean is not a verified negative. The API reference documents the full response contract.
Should a detected VPN block the customer?
Not automatically. Under Maskbreak’s base policy, VPN use alone produces review. Account rules and other signals can change the final recommendation. Your application can use a server-verified step, such as existing MFA, when the action warrants it. Read how to avoid rejecting legitimate VPN users.
Handle an incomplete check or timeout separately from a completed check with no detected warning signs. Pick a fallback based on the action: pausing a sensitive change may be reasonable, while a low-risk page view may not need to wait. Show a useful retry or support path instead of an unexplained error.
How do you test it before going live?
First exercise response handling with documented fixtures. The public sample endpoint needs no account, and the sandbox key sk_test_sandbox returns a fixed decision for each test token:
curl --fail-with-body 'https://maskbreak.com/v1/evaluate/sample?scenario=vpn'
curl --fail-with-body https://maskbreak.com/v1/evaluate \
-H 'Authorization: Bearer sk_test_sandbox' \
-H 'Content-Type: application/json' \
-d '{"token":"test_vpn"}'| Sandbox token | Base decision | What it exercises |
|---|---|---|
test_clean | allow | The path to your existing handler |
test_vpn | review | Your step-up or review flow |
test_datacenter | allow | A signal without a base block |
test_proxy | block | The refusal path |
test_tor | block | The refusal path |
Your production guard should reject these results, because sandbox responses are marked as tests; run the fixtures in staging with the protected action stubbed out. Then test live collection on your own normal browser, a VPN connection and a privacy-restricted browser. Confirm that your server receives both evidence fields, and deliberately test what happens when either is absent. Fixture results cannot establish VPN detection coverage.
Maskbreak’s Free plan includes 10,000 visitor checks a month with no credit card; paid plans start at €29 a month. Authenticated IP lookups have their own allowance, ten times the plan’s visitor checks. Start with a small live test, confirm the deployed endpoint actually enforces its decision, and measure successful customer completion as well as flagged traffic. To compare other free options, see free VPN detection APIs compared.
Questions people ask
- Can JavaScript detect a VPN without an API?
- JavaScript can collect browser and connection clues, but no browser-only check reliably identifies every VPN. Maskbreak combines live visit evidence with server-side network and device checks; results still depend on the evidence available.
- Can JavaScript detect a VPN from the time zone?
- It can spot a difference between the time zone the device reports and the country of the IP address. That is a clue, not a verdict: travellers, roaming phones, remote workers and people who set their clock by hand produce the same difference, and a VPN exit in the visitor’s own country produces none. Maskbreak accepts the time zone as the optional tz field and moves an allow to review on an eligible mismatch, never to a block.
- Why does WebRTC no longer show local IP addresses?
- Private addresses from WebRTC host candidates were used to fingerprint and track visitors, so current mainstream browsers replace them with random .local names or leave them out unless the page has camera or microphone permission. The public address a STUN server reports can still appear, but browser settings, extensions and VPN apps can suppress it.
- Should I put my VPN detection API key in frontend JavaScript?
- No. Keep the Maskbreak API key in your backend environment or secret store. Send browser evidence to your own backend, which calls the evaluation API and enforces the result.
- Does a WebRTC IP address reveal every VPN?
- No. Address exposure depends on the browser, permissions, network configuration and routing. A missing address or a different address does not by itself establish whether a VPN is in use; a dual-stack visitor can even show an IPv6 address to your server and an IPv4 address to STUN.
- Can I try JavaScript VPN detection for free?
- Yes. Maskbreak’s Free plan includes 10,000 visitor checks a month with no credit card; paid plans start at €29 a month. Check the current pricing and terms before rollout; the public sandbox tests response handling, not live detection accuracy.
Paste it in, then watch the verdicts
The public sk_test_sandbox key returns the documented allow, review and block shapes with no account, so the failure path is testable before you go live. SDKs for Node, Python and PHP, or plain HTTP. The API reference and the Free plan cover the rest.



