On this page
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
npm install express vpndetection-expressThe 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.
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:
curl -s localhost:3000/ -H 'X-Forwarded-For: 23.165.56.3' | jq 'del(.result.raw)'{
"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 proxy | req.ip | isVpn |
|---|---|---|
| not set | ::1 | false |
true | 1.1.1.1 | false |
1 | 23.165.56.3 | true |
'loopback' | 23.165.56.3 | true |
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:
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:
npm install vpndetection express-rate-limitimport 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:
// 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:
| Visitor | Requests a minute | POST /signup | POST /checkout |
|---|---|---|---|
| Mullvad server | 20 | {"confirmEmail":true} | 403 |
| Home broadband | 100 | {"confirmEmail":false} | 200 |
Residential proxy exit, hits 36 | 20 | {"confirmEmail":true} | 403 |
Residential proxy exit, hits 2 | 20 | {"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:
{
"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:
{
"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.


