On this page
This tutorial adds VPN detection to a Fastify app with vpndetection-fastify, our official plugin. By the end, signup asks visitors on a VPN, a residential proxy or Tor to confirm their email, checkout turns them away, and the rest of the app never makes a lookup. We ran each snippet against the live API on October 8, 2026, with Node.js 22.22 and Fastify 5.12.5, and every output below is from those runs. The finished app also runs unchanged on Fastify 4.29.1.
Look up every request
npm install fastify vpndetection-fastifyThe plugin needs Node.js 22 or newer and Fastify 4 or 5. 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 Fastify from 'fastify';
import { vpndetection } from 'vpndetection-fastify';
// nginx on this machine: see "Get the client address right"
const app = Fastify({ trustProxy: 'loopback' });
await app.register(vpndetection, { apiKey: process.env.VPNDETECTION_API_KEY });
app.get('/', async (request) => request.vpndetection);
await app.listen({ port: 3000 });Every request now carries request.vpndetection, and nothing is blocked.
To try it without a VPN, send the header a proxy in front of the app would add. 23.144.160.67 is a Mullvad server in San Jose, 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.144.160.67' | jq 'del(.result.raw)'{
"ip": "23.144.160.67",
"result": {
"ip": "23.144.160.67",
"isVpn": true,
"isBogon": false,
"isHosting": false,
"isRelay": false,
"isTor": false,
"isCdn": false,
"isResproxy": false,
"isDcproxy": false,
"isMobproxy": false,
"vpn": {
"provider": "mullvad",
"last_seen": "2026-10-06",
"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 plugin answers itself without a request.
Get the client address right
The plugin looks up request.ip, and Fastify sets that to whatever opened the connection unless the server was built with trustProxy. 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. nginx's usual $proxy_add_x_forwarded_for adds the address it sees to the end of that header, as AWS's Application Load Balancer and Cloudflare do, so the request came from 127.0.0.1 carrying 1.1.1.1, 23.144.160.67. Then the visitor sent the same header straight to the app. The address the plugin looked up:
trustProxy | Through nginx | Straight to the app |
|---|---|---|
| not set | 127.0.0.1 | 23.144.160.67 |
true | 1.1.1.1 | 1.1.1.1 |
1 on Fastify 5.12.5 | 127.0.0.1 | 23.144.160.67 |
1 on Fastify 4.29.1 | 23.144.160.67 | 1.1.1.1 |
'loopback' | 23.144.160.67 | 23.144.160.67 |
Only 23.144.160.67 finds the VPN, and only 'loopback', which names the proxy, gets it both ways.
Unset, every visitor through nginx is nginx itself. The plugin answers a loopback address locally as private, so nobody is ever flagged, and the one sign of it is a line in your log:
[vpndetection] resolved the client address as 127.0.0.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.
true believes every entry, so request.ip is the left-most one, which the visitor wrote: a VPN user is looked up as any address they like.
A number is where Fastify parts ways with Express. On Fastify 4.29.1, trustProxy: 1 believes whatever connects, so a visitor who reaches the app directly picks the address it sees. Fastify 5.12.1 closed that as a security fix (GHSA-3m5p-2c4r-xxw2) by making a number trust nothing, with no error or warning. An app that set one before the upgrade keeps running, and behind nginx it now looks up nginx, as if trustProxy were unset.
So name the proxy. 'loopback' covers both 127.0.0.1 and ::1: under '127.0.0.1', an nginx that connects over ::1 isn't believed, and every visitor arrives as ::1. For a load balancer on another machine, give its subnet, such as '10.0.0.0/8'.
Behind a CDN, request.ip is the CDN. Sent from a Cloudflare address with trustProxy unset, the plugin looked up that address and found isCdn: true. Read the header the CDN writes instead:
import { headerIpSelector, vpndetection } from 'vpndetection-fastify';
await app.register(vpndetection, {
apiKey: process.env.VPNDETECTION_API_KEY,
ipSelector: headerIpSelector('CF-Connecting-IP'), // True-Client-IP on Akamai
});From Cloudflare's address, CF-Connecting-IP: 23.144.160.67 came back as the VPN. Straight to the app, CF-Connecting-IP: 1.1.1.1 was looked up as 1.1.1.1, so the header is only as trustworthy as your origin is closed: let only Cloudflare's ranges reach it.
Check only the routes that need it
A plugin registered on the root instance runs for every route, so your health check, your docs and your static files each cost a lookup on a cache miss. Fastify's encapsulation confines it: registered inside a plugin of your own, it runs only for that plugin's routes. A VPN visitor who's only reading needs no check. Signup and checkout are where it matters, so this app looks up those two and nothing else. It needs one more package:
npm install vpndetectionimport Fastify from 'fastify';
import { VPNDetection } from 'vpndetection';
import { vpndetection } from 'vpndetection-fastify';
// One client, so both scopes share its cache and a visitor costs one lookup.
const client = new VPNDetection({ apiKey: process.env.VPNDETECTION_API_KEY });
const app = Fastify({ trustProxy: 'loopback' });
// Outside both scopes, so never looked up.
app.get('/', async (request) => ({ checked: request.vpndetection !== undefined }));
// Signup: nobody is refused, but a masked visitor confirms their email first.
await app.register(async (signup) => {
await signup.register(vpndetection, { client });
signup.post('/signup', async (request) => {
const { result, error } = request.vpndetection;
const masked = result?.isVpn || result?.isResproxy || result?.isTor;
return { confirmEmail: Boolean(masked) || error !== undefined };
});
});GET / answers {"checked":false} for every visitor. Outside the scope, request.vpndetection is undefined, which is why its TypeScript type is optional. A failed lookup asks for the email too, since it's no answer at all.
Keep that plugin encapsulated. Wrapped in fastify-plugin, as shared plugins often are, it hands the hook to the root instance: we tried it, and GET / was looked up again, though it was declared first.
Block at checkout
Checkout gets a scope of its own, beside signup's, with a blockCondition. A matching request gets the onBlocked answer and never reaches the route, and with the same client it costs no second lookup:
// Checkout: refuse a VPN, Tor, or a residential proxy exit seen 5 times or more.
await app.register(async (checkout) => {
await checkout.register(vpndetection, {
client,
blockCondition: [
{ isVpn: true },
{ isTor: true },
{ isResproxy: true, resproxy: { hits: { gte: 5 } } },
],
onBlocked: (request, reply) => reply.code(403).send({
error: 'Please turn off your VPN or proxy to check out.',
}),
});
checkout.post('/checkout', async () => ({ ordered: true }));
});
await app.listen({ port: 3000 });From version 2.0.10, checkout's scope can also sit inside one that already has the plugin, such as a scope that flags every account page. Both run, the outer one first, so a nested scope can add a block but never lift one, and the shared client still makes it one lookup per visitor.
We sent both routes the Mullvad server, the home address, and two residential proxy exits from our data, one with hits at 31 and one at 2. The app called the API once per visitor:
| Visitor | POST /signup | POST /checkout |
|---|---|---|
| Mullvad server | {"confirmEmail":true} | 403 |
| Home broadband | {"confirmEmail":false} | 200 |
Residential proxy exit, hits 31 | {"confirmEmail":true} | 403 |
Residential proxy exit, hits 2 | {"confirmEmail":true} | 200 |
The plugin runs in Fastify's onRequest hook, before the body is read, so a refused request costs no parsing or validation. Sent with a malformed JSON body, the Mullvad visitor still got the 403, and the home visitor a 400 for the body.
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"}.
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. The app fails to start 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 plugin 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 request.vpndetection.error. With an invalid key, the first app answered:
{
"ip": "23.144.160.67",
"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 got the block answer instead, 403 and {"error":"access denied"}, which at checkout turns an outage of ours into lost orders. Fail open, and ask a visitor whose lookup failed for more, as the signup route does.
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; the timeoutMs and retries options 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.144.160.67",
"result": {
"ip": "23.144.160.67",
"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 with no key, checkout still refuses the Mullvad server on isVpn, but its Tor and proxy terms can never match, and the plugin 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
Our Express tutorial uses the same options and adds per-route rate limits, and Next.js has a middleware of its own. The Fastify plugin is on npm with its source on GitHub, and every field the API answers is in the docs.


