# How to detect VPN users in Fastify

Detect VPN, proxy and Tor visitors in Fastify with the official plugin, tested live: the trustProxy hop-count change, per-route scopes and failed lookups.

This tutorial adds VPN detection to a Fastify app with [`vpndetection-fastify`](https://docs.vpndetection.io/integrations/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

```bash
npm install fastify vpndetection-fastify
```

The plugin needs Node.js 22 or newer and Fastify 4 or 5. Get an API key in the [console](https://app.vpndetection.io): 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 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](https://mullvad.net/en/servers). `raw`, the API's answer as it came off the wire, is left out:

```bash
curl -s localhost:3000/ -H 'X-Forwarded-For: 23.144.160.67' | jq 'del(.result.raw)'
```

```json
{
  "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](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/x-forwarded-headers.html) and [Cloudflare](https://developers.cloudflare.com/fundamentals/reference/http-headers/) 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](https://github.com/fastify/fastify/security/advisories/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:

```js
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](https://www.cloudflare.com/ips/) 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:

```bash
npm install vpndetection
```

```js
import 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:

```js
// 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:

```json
{
  "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](https://vpndetection.io/pricing)). With a few million distinct visitors a day, download the [VPN IP database](https://vpndetection.io/databases/vpn-ip) and look addresses up locally instead of calling the [API](https://vpndetection.io/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.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](https://vpndetection.io/blog/detect-vpn-users-express-js) uses the same options and adds per-route rate limits, and [Next.js](https://docs.vpndetection.io/integrations/nextjs) has a middleware of its own. The Fastify plugin is on [npm](https://www.npmjs.com/package/vpndetection-fastify) with its source on [GitHub](https://github.com/vpndetection-io/sdk-nodejs-fastify), and every field the API answers is in the [docs](https://docs.vpndetection.io).

