How to Detect VPN Users: Tested on 4,690 IPs

We ran Mullvad, IVPN, Tor and 2,852 home connections through VPN, Tor and blacklist checks. What each one catches, and Node.js middleware to use them.

  • 7 min read
  • Published March 5, 2026
  • Updated October 22, 2026
Article featured image

Reading only is_vpn misses most of the VPN servers we tested. Look up the visitor’s IP with a VPN detector and read is_datacenter as well. We checked both fields against IP addresses whose answer we already knew. Every one of Mullvad’s 533 WireGuard servers came back is_vpn: true. Only 23 of IVPN’s 89 servers did, but 81 of the 89 were flagged as datacenter addresses. Across 2,852 ordinary home connections, 22 were flagged either way, which is 0.8%. Tor is a separate question and needs its own check.

The middleware at the end labels a request instead of blocking it, because the test also showed how often these signals land on ordinary people.

How we tested

We used four lists with known answers:

  • 533 Mullvad VPN servers, from Mullvad’s public relay list (their WireGuard entry addresses);
  • 89 IVPN servers, from IVPN’s public server list, resolved to IP addresses;
  • 1,216 Tor exit nodes, from the Tor Project’s bulk exit list that day;
  • 2,852 home internet connections, from RIPE Atlas probes tagged as home connections (the same set we used to measure IP geolocation accuracy).

Each address went through three of our endpoints: the VPN Detector, the Tor Detector and the IP Blacklist Lookup.

VPN flagDatacenter flagVPN or datacenterTor detectorOn a blacklist
Mullvad (533)100%90.4%100%0%0.4%
IVPN (89)25.8%91.0%91.0%0%1.1%
Tor exits (1,216)22.3%45.1%45.1%99.1%99.9%
Home connections (2,852)0.1%0.8%0.8%0.1%1.5%

VPN providers can exit traffic from a different address than the one they publish for connecting, so the server lists are a close stand-in for exit addresses, not a perfect one. And RIPE Atlas hosts are network enthusiasts; a few of them run their home connection through a VPN or host a Tor relay, which accounts for some of the “home” flags.

Reading the table

The VPN flag is a list of known VPN providers. Mullvad is big and well known, so it’s on every list, and 100% detection is what you’d expect. IVPN is smaller and runs many servers on rented infrastructure. Of the 66 addresses not tagged as VPN, 28 sit on CDN77’s network and 10 on Tzulo’s. They aren’t on the VPN lists, but they’re unmistakably hosting addresses, so the datacenter flag catches them. If you read only is_vpn, you miss three quarters of a real VPN provider.

The datacenter flag is the broader net, and it’s quiet on home connections: 22 of 2,852. The biggest group, 5, were on OVH Telecom, which is the consumer broadband arm of the hosting company OVH. The flag reads who owns the address block, not what the connection is used for, so a hosting company that also sells home internet gets its customers labelled as servers. That isn’t the only place it misfires. Earlier this year we removed a rule from our own signup form that refused datacenter addresses, because university and institutional networks are often registered to hosting providers and real students were being turned away.

Tor needs its own check. The Tor detector found 1,205 of the 1,216 exits on the list. The VPN detector flagged only 22% of them as VPN and 45% as datacenter, because many Tor exits are run by volunteers on ordinary connections. Use the Tor detector for Tor.

The blacklist column is the one that should make you careful. 99.9% of Tor exits are on it, at the “critical” level. But so are 42 of the 2,852 home connections, 1.5%, and 39 of those 42 were on regular consumer ISPs (Philippine and Dutch broadband among them), at the “high” level for inbound activity. That’s what happens when one infected machine behind a shared address earns the listing for everyone on it. On our own signup form, every blacklist refusal in the last 30 days had exactly that “high, inbound only” profile.

The middleware

This labels each request tor, vpn, hosting, direct or unknown, and leaves the decision to the route. It needs Express.

// vpn-gate.js: Express middleware that labels a request by how it reaches you
const KEY = process.env.APIVERVE_API_KEY;
const cache = new Map(); // ip -> { label, at }
const TTL_MS = 60 * 60 * 1000;

async function lookup(api, ip) {
  const res = await fetch(`https://api.apiverve.com/v1/${api}?ip=${encodeURIComponent(ip)}`, {
    headers: { 'x-api-key': KEY },
    signal: AbortSignal.timeout(3000),
  });
  if (!res.ok) throw new Error(`${api} returned ${res.status}`);
  return (await res.json()).data;
}

async function classify(ip) {
  const hit = cache.get(ip);
  if (hit && Date.now() - hit.at < TTL_MS) return hit.label;

  const [vpn, tor] = await Promise.all([lookup('vpndetector', ip), lookup('tordetect', ip)]);
  let label = 'direct';
  if (tor.isTor) label = 'tor';
  else if (vpn.is_vpn) label = 'vpn';
  else if (vpn.is_datacenter) label = 'hosting'; // most small VPNs only show up here

  cache.set(ip, { label, at: Date.now() });
  return label;
}

// Use on the routes where it matters (signup, checkout), not on every page view.
function ipLabel() {
  return async (req, res, next) => {
    const ip = req.get('cf-connecting-ip') || req.ip; // see "the real IP" below
    try {
      req.ipLabel = await classify(ip);
    } catch (err) {
      req.ipLabel = 'unknown'; // fail open: treat like direct, but you can see it in logs
    }
    next();
  };
}

module.exports = { ipLabel, classify };

if (require.main === module) {
  (async () => {
    for (const ip of process.argv.slice(2)) console.log(ip.padEnd(16), await classify(ip));
  })();
}

Run on a Mullvad server in Vienna, an IVPN server in Auckland, a Tor exit and an ETH Zurich web server:

$ node vpn-gate.js 135.136.51.130 103.75.11.219 171.25.193.25 129.132.19.216
135.136.51.130   vpn
103.75.11.219    hosting
171.25.193.25    tor
129.132.19.216   direct

The same four labels came back on a free key and a paid one. The paid plan adds is_tor, risk_level and the ASN name to the VPN detector’s response, which help when you’re reviewing flagged accounts by hand but aren’t needed for the label.

Using it on a route looks something like this (createUser stands in for your own code):

app.post('/signup', ipLabel(), async (req, res) => {
  const needsEmailCode = ['tor', 'vpn', 'hosting'].includes(req.ipLabel);
  const user = await createUser(req.body, { needsEmailCode, ipLabel: req.ipLabel });
  res.status(201).json({ id: user.id, verify: needsEmailCode ? 'email-code' : null });
});

The real IP, the cache and the failure mode

The real IP comes first, for the reasons in the signup post: check your proxy’s address by mistake and every visitor gets the same label.

Results are cached per IP for an hour. VPN and hosting assignments change slowly, and a signup form sees the same address several times in a few minutes when someone retries. The cache here is in memory; with more than one server, put it in Redis or whatever you already share.

Lookups that fail produce unknown, which the route treats like direct. Logging it separately lets you see how often that happens.

And the label adds friction instead of refusing. For signups, a VPN or hosting address gets an emailed code before the account works. For checkout, it might mean 3-D Secure or a manual review above some amount. People use VPNs at work, on hotel Wi-Fi and for privacy, and the 0.8% false-flag rate on home connections is small but it isn’t zero.

When we’d block outright

Tor on payment and account-creation routes, if your product has no reason to serve anonymous users. The Tor detector is close to exact, the exit list is public, and abuse through Tor is cheap to attempt.

A blacklist hit on its own is a weaker reason than it looks. On home connections it was wrong 1.5% of the time in this test, more often than the VPN and datacenter flags combined, and the people it catches are mostly on shared carrier addresses they didn’t choose.

Give your software a way to act on the world.

Scaling in production?

The same key runs from your first prototype to millions of calls — on APIVerve's rails, backed by an uptime SLA.

See pricing