Most "is this visitor on a VPN?" tutorials do the same thing: take the visitor's IP, send it to a lookup API, get a yes or no. It works for the obvious cases. It falls short exactly where fake signups, trial abuse and card testing come from.
This post covers what an IP can and can't tell you, why checking in the browser alone doesn't work either, and the pattern that does: collect evidence in the browser, decide on your server.
What an IP address can tell you
An IP tells you who owns the address. It doesn't tell you how the visitor got there.
| Question | IP lookup alone |
|---|---|
| Is it a Tor exit node? | Yes, the exit list is public |
| Is it a cloud or datacenter range (AWS, GCP, Hetzner…)? | Yes, providers publish their ranges |
| Is it a commercial VPN exit? | Sometimes, depending on how fresh the list is |
| Is it a residential proxy? | Hard: the address belongs to a real home ISP |
| Is the browser automated or an antidetect browser? | No, that lives in the browser, not the IP |
Residential proxies are the problem case. They route traffic through real home connections, so the IP looks like an ordinary broadband customer. And the tools people use for multi-accounting (antidetect browsers like GoLogin, AdsPower, Multilogin) change the browser, not just the network.
Why checking in JavaScript alone doesn't work
The classic browser-side tricks:
-
Timezone vs IP country. Compare
Intl.DateTimeFormat().resolvedOptions().timeZonewith the IP's country. It flags VPN users, but also everyone travelling, working remotely or with a misconfigured laptop. - WebRTC leaks. This used to expose the real local IP. Modern browsers hide local addresses (mDNS), and most VPN apps block the leak.
Both run in code the visitor controls. Anything your frontend decides can be edited in DevTools. So the browser should collect evidence, and your server should decide.
The pattern: evidence in the browser, decision on the server
- A script on the signup page collects network and browser evidence and adds it to the form as hidden fields.
- The form posts to your backend as usual.
- Your backend sends that evidence (plus your secret API key) to a detection API and gets a verdict back.
- Your code decides: continue, ask for one more step, or stop.
Here's what that looks like with Maskbreak (the API I build; it was called Sentinel until August). The same shape works with any provider that evaluates a live visit.
The page with the form:
<script async src="https://maskbreak.com/assets/sentinel.js"></script>
<form class="monocle-enriched" method="POST" action="/signup">
<!-- your existing inputs; the script adds two hidden fields -->
</form>
Your server (Express):
app.post('/signup', async (req, res, next) => {
const token = req.body.monocle; // network evidence
const fingerprintEventId = req.body.sentinel_fp; // browser evidence
let result = null;
try {
const r = 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 })
});
if (r.ok) result = await r.json();
} catch {
// evidence unavailable: record it, don't guess
}
console.log('signup check', result?.decision, result?.reasons);
req.fraudCheck = result; // week one: watch only, never block
next();
}, existingSignupHandler);
The key stays on the server. Never put it in frontend code.
A response looks like this (a VPN visit):
{
"decision": "review",
"risk_score": 65,
"country": "NL",
"network": {
"vpn": true,
"proxy": false,
"datacenter": true,
"tor": false,
"service": "PROTON_VPN"
},
"reasons": ["vpn_detected", "datacenter_asn"]
}
network.service names the VPN or proxy when it's known; otherwise it's null.
Treat a VPN as "review", not "block"
Plenty of legitimate people use VPNs: privacy-minded users, people on hotel Wi-Fi, whole companies. Blocking every VPN costs you real signups.
A better default:
- allow: continue your normal checks.
- review: ask for one more step (an email code, a phone check, manual approval).
- block: stop. Keep this for strong evidence: residential proxies, Tor, automation, tampered browsers.
In code, once you're happy with what you've seen in the logs:
const d = req.fraudCheck?.decision;
if (d === 'block') return res.status(403).json({ error: 'Request declined.' });
if (d === 'review') return res.status(409).json({ next: 'verify_email' });
Start by watching, not blocking
Turn it on in log-only mode for a week. Look at what would have been reviewed or blocked, check a few by hand, then switch the two lines above on. You'll find out how much of your traffic is VPN (often more than you'd guess) without locking out a single real user.
Also decide up front what happens when evidence is missing, for example when an ad blocker stops the script. For a newsletter signup, let it through. For a payout, hold it.
Testing without real traffic
You don't need a VPN to test your branches. Maskbreak has a public sample endpoint and sandbox tokens:
curl 'https://maskbreak.com/v1/evaluate/sample?scenario=vpn'
curl https://maskbreak.com/v1/evaluate \
-H 'Authorization: Bearer sk_test_sandbox' \
-H 'Content-Type: application/json' \
-d '{"token":"test_proxy"}'
| Token | Decision |
|---|---|
test_clean |
allow |
test_vpn |
review |
test_proxy |
block |
test_tor |
block |
These are contract tests for your code paths, not a measure of detection quality.
Wrap-up
- An IP lookup is good for Tor and cloud ranges. It can't see residential proxies or what the browser is doing.
- Browser-only checks are easy to fake. Collect evidence in the browser, decide on the server.
- VPN on its own means review, not block.
- Watch first, enforce later.
Maskbreak is free during its open beta: 1,000 visitor checks an hour per API key, no card. There's a live check of your own visit on the homepage: https://maskbreak.com. Docs: https://maskbreak.com/api
Top comments (0)