Skip to content
VPNDetection

How to detect VPN users in Express.js

Detect VPN, proxy and Tor visitors in Express with the official middleware, tested live: the trust proxy trap, per-route rules and failed lookups.

Published 5 min read

On this page
  1. Look up every request
  2. Get the client address right
  3. Decide per route
  4. Block at checkout
  5. When a lookup fails
  6. Absent is not false
  7. Next steps

This tutorial adds VPN detection to an Express app with vpndetection-express, our official middleware. By the end, every request knows whether its visitor is on a VPN, a residential proxy or Tor, signup asks those visitors to confirm their email, and checkout turns them away. We ran each snippet against the live API on September 29, 2026, with Node.js 22.22 and Express 5.2.1, and every output below is from those runs. The finished app also runs unchanged on Express 4.22.

Look up every request

bash
npm install express vpndetection-express

The middleware needs Node.js 22 or newer and Express 4.18 or newer. Get an API key in the console: a free one covers 50,000 lookups a month. Without a key you get 1,000 lookups a day per source address, and in production the source address is your server, for every visitor.

js
import express from 'express';
import { vpndetection } from 'vpndetection-express';

const app = express();
app.set('trust proxy', 'loopback'); // a proxy on this machine: see the next section
app.use(vpndetection({ apiKey: process.env.VPNDETECTION_API_KEY }));

app.get('/', (req, res) => {
    res.json(req.vpndetection);
});

app.listen(3000);

Every request now carries req.vpndetection, and nothing is blocked: what a VPN visitor means is your call, route by route.

To try it without a VPN, send the header a proxy in front of the app would add. 23.165.56.3 is a Mullvad server in New York, from Mullvad's own server list. raw, the API's answer as it came off the wire, is left out:

bash
curl -s localhost:3000/ -H 'X-Forwarded-For: 23.165.56.3' | jq 'del(.result.raw)'
json
{
  "ip": "23.165.56.3",
  "result": {
    "ip": "23.165.56.3",
    "isVpn": true,
    "isBogon": false,
    "isHosting": false,
    "isRelay": false,
    "isTor": false,
    "isCdn": false,
    "isResproxy": false,
    "isDcproxy": false,
    "isMobproxy": false,
    "vpn": {
      "provider": "mullvad",
      "last_seen": "2026-09-28",
      "confidence": "high",
      "method": "scan"
    },
    "hosting": {},
    "relay": {},
    "tor": {},
    "cdn": {},
    "resproxy": {},
    "dcproxy": {},
    "mobproxy": {}
  },
  "blocked": false
}

That's the answer for a key on the Max plan. A home broadband address on a US cable ISP came back with every flag false and every detail object empty. isBogon is true only for a private or reserved address, which the middleware answers itself without a request.

Get the client address right

The middleware looks up req.ip, and Express sets req.ip to whatever opened the connection unless trust proxy says which proxies to believe. Behind a proxy, that's the proxy.

We sent the app the request nginx would forward for a visitor on the Mullvad server who wrote their own X-Forwarded-For: 1.1.1.1. The usual nginx config, AWS's Application Load Balancer and Cloudflare append the address they see to that header rather than replace it, so it arrived as 1.1.1.1, 23.165.56.3:

trust proxyreq.ipisVpn
not set::1false
true1.1.1.1false
123.165.56.3true
'loopback'23.165.56.3true

Unset, req.ip is nginx itself, which on the same machine is a loopback address. The middleware answers that locally as private, so no visitor is ever flagged, and the one sign of it is a line in your log:

[vpndetection] resolved the client address as ::1, which is not a public address. If this application runs behind a proxy or load balancer, configure its trusted-proxy setting or pass an ipSelector that reads your edge's header.

Behind Cloudflare, req.ip is one of Cloudflare's edge addresses, and every visitor comes back isCdn: true.

true trusts every entry, so req.ip is the left-most one, which the visitor wrote: anyone on a VPN passes as clean by sending the header.

1 and 'loopback' agree here, and part ways when a visitor reaches the app without going through nginx. We sent X-Forwarded-For: 1.1.1.1 straight to the app's public address. Under 1, req.ip was 1.1.1.1, because a hop count trusts whatever connects; under 'loopback', it was the sender's own address. So name your proxies: 'loopback' for nginx on the same machine, and your load balancer's subnet when it runs elsewhere ('10.0.0.0/8', or 'uniquelocal' for all the private ranges). A hop count is safe only when nothing but your proxy can reach the app.

Behind a CDN, you can also read the header it writes:

js
import { headerIpSelector, vpndetection } from 'vpndetection-express';

app.use(vpndetection({
    apiKey: process.env.VPNDETECTION_API_KEY,
    ipSelector: headerIpSelector('CF-Connecting-IP'), // True-Client-IP on Akamai
}));

Cloudflare sets CF-Connecting-IP on every request it forwards, so the header is as trustworthy as your origin is closed: anyone who can reach the app directly can send their own. xffIpSelector({ depth: 1 }), from the same package, is a hop count for the middleware alone, with the same caveat.

Decide per route

Someone reading your docs over a VPN isn't a problem. A new account from a Tor exit may be, and so may a card payment from a residential proxy. So look everyone up once and act per route. This needs two more packages:

bash
npm install vpndetection express-rate-limit
js
import express from 'express';
import { rateLimit } from 'express-rate-limit';
import { VPNDetection } from 'vpndetection';
import { vpndetection } from 'vpndetection-express';

// One client, so both middlewares share its cache and a visitor costs one lookup.
const client = new VPNDetection({ apiKey: process.env.VPNDETECTION_API_KEY });

const app = express();
app.set('trust proxy', 'loopback');

// Every request is looked up, and none is blocked here.
app.use(vpndetection({ client }));

// A VPN, residential proxy or Tor visitor, as far as your plan can tell.
function isMasked(req) {
    const r = req.vpndetection?.result;
    return Boolean(r?.isVpn || r?.isResproxy || r?.isTor);
}

// Browsing: a masked visitor gets a fifth of the usual rate.
app.use(rateLimit({ windowMs: 60_000, limit: (req) => (isMasked(req) ? 20 : 100) }));

app.get('/', (req, res) => res.send('ok\n'));

// Signup: nobody is refused, but a masked visitor confirms their email first.
app.post('/signup', (req, res) => {
    const confirmEmail = isMasked(req) || req.vpndetection.error !== undefined;
    res.json({ confirmEmail });
});

A failed lookup asks for the email too, since it's no answer at all.

Block at checkout

Blocking takes a blockCondition. Mounted on one route with the same client, it costs no second lookup, because the answer is already in the client's cache:

js
// Checkout: a VPN, Tor, or a residential proxy exit seen 5 times or more is refused.
app.post('/checkout',
    vpndetection({
        client,
        blockCondition: [
            { isVpn: true },
            { isTor: true },
            { isResproxy: true, resproxy: { hits: { gte: 5 } } },
        ],
        onBlocked: (req, res) => res.status(403).json({
            error: 'Please turn off your VPN or proxy to check out.',
        }),
    }),
    (req, res) => res.json({ ordered: true }),
);

app.listen(3000);

A condition has the shape of a result, and only the members you name count. A list matches when any of its conditions does, an array value when any of its values does ({ vpn: { confidence: ['high', 'medium'] } }), and { gte, gt, lte, lt } compares numbers. Without onBlocked, a blocked request gets 403 and {"error":"access denied"}.

We sent each route the Mullvad server, the home address, and two residential proxy exits from our data, one with hits at 36 and one at 2. The app called the API once per visitor:

VisitorRequests a minutePOST /signupPOST /checkout
Mullvad server20{"confirmEmail":true}403
Home broadband100{"confirmEmail":false}200
Residential proxy exit, hits 3620{"confirmEmail":true}403
Residential proxy exit, hits 220{"confirmEmail":true}200

A condition that constrains nothing is refused. { isVpn: false } reads like "let non-VPN visitors through", but a member set to false or null is ignored, so it would match every request and block all of them. The middleware throws when it's created instead:

Error: vpndetection: blockCondition constrains nothing, which would block every request. A member set to false or null is ignored; state the positive signals you act on.

When a lookup fails

The middleware fails open. When our API can't be reached, your quota is spent or the key is wrong, the request goes through with the reason on req.vpndetection.error. With an invalid key, the first app answered:

json
{
  "ip": "23.165.56.3",
  "error": {
    "kind": "unauthorized",
    "status": 401,
    "name": "VPNDetectionError"
  },
  "blocked": false
}

kind is one of bad_request, unauthorized, forbidden, rate_limited, quota_exceeded, server_error or network. With failClosed: true, the same request gets the block response instead: 403 and {"error":"access denied"}. At checkout, that turns an outage of ours into lost orders. Fail open, and ask a visitor whose lookup failed for more: the signup route above does, and a checkout can require 3-D Secure.

Answers are cached per client for an hour, up to 10,000 addresses, so a returning visitor costs nothing, and private and reserved addresses never leave the process. A miss is one request, which holds the visitor for at most 2,500 ms and isn't retried; timeoutMs and retries change both. A free key covers 50,000 lookups a month, and paid plans start at 250,000 (pricing). With a few million distinct visitors a day, download the VPN IP database and look addresses up locally instead of calling the API.

Absent is not false

With no key, or on the free plan, the Mullvad request from the first section answers only this:

json
{
  "ip": "23.165.56.3",
  "result": {
    "ip": "23.165.56.3",
    "isVpn": true,
    "isBogon": false
  },
  "blocked": false
}

isTor, isResproxy and the rest aren't false there. They're undefined, which means "not in your plan", not "checked, and no". isVpn is on every plan, the hosting and Tor flags start on Starter, the proxy flags on Scale, and resproxy.hits is on Max.

So on the free plan, the checkout's Tor and proxy terms can never match, and the middleware says so once:

[vpndetection] blockCondition names isTor, isResproxy, resproxy, which your plan does not include, so those terms can never match. An absent member means "not in your plan", not "checked, and no".

onMissingField: 'throw' makes that an error. In your own code, result.isTor === false means checked and not Tor, and result.isTor ?? false is for when you only want the flag.

Next steps

Middleware for Fastify and Next.js takes the same blockCondition, ipSelector and failClosed options. The Express package is on npm with its source on GitHub, and every field the API answers is in the docs.

Written by

  • Uman Shahzad

    Founder & CEO

    IP intelligence data expert for over 8 years, former lead engineer at IPinfo, Uman has pioneered many systems and practices used by IP data companies to operate at scale, provide high-quality data and offer the best developer experiences.

Get new posts by email

Or follow along in a feed reader.

RSS feed

Try it on your own traffic.

The detection these posts describe is one API call away, with SDKs for every major language.

00 · No key

Start without signing up.

Paste it into a terminal. No account needed. 1k daily allowance per user, answering ip and is_vpn.

curl "https://api.vpndetection.io/45.83.91.1"
{
"ip": "45.83.91.1",
"is_vpn": true
}
01 · Free key

Sign up and get 50,000 requests a month.

No card and no sales call: the key is issued on signup.
Every request flags VPN IPs.
Upgrade only when you outgrow the allowance.