# VPN detection on IPv6: check the address, key the ban by /64

Check each IPv6 visitor's address for a VPN, but count and ban by /64: what VPN exits look like on IPv6, and Express code for both, tested.

A visitor on IPv6 changes two things about a VPN check, and the lookup is neither. The address comes in more than one spelling, and the visitor holds a whole /64 of them, 2^64 addresses: their devices give themselves new [temporary addresses](https://www.rfc-editor.org/rfc/rfc8981) in it as they go, and [NyxProxy](https://github.com/phanes-io/nyxproxy-oss), shown on Hacker News in November 2025 as a way to "Destroy rate limits", gives "a new IPv6 address for every request" from a server's /64. Check each address for a VPN as it arrives, and count, rate-limit and ban by /64. The numbers below are from our VPN IP data on October 18, 2026, and the code ran against the live API the same day.

## What VPN exits look like on IPv6

On October 18, 109 of the 573 consumer VPN services in our [VPN IP](https://vpndetection.io/databases/vpn-ip) data had IPv6 exits listed, about one in five. VPN IP lists 207,980 of those exits one address at a time, and they're packed together:

| /64s, by the VPN exits listed in each | /64s | Exits listed | Share |
|---|---|---|---|
| 1,000 or more | 30 | 154,138 | 74.1% |
| 100 to 999 | 137 | 50,224 | 24.1% |
| 2 to 99 | 450 | 1,851 | 0.9% |
| 1 | 1,767 | 1,767 | 0.8% |
| All | 2,384 | 207,980 | 100% |

The busiest /64 holds 11,271. Where a provider's exits fill a /64 like that, VPN IP lists the whole /64: 691 of them, holding 99.0% of the addresses above, besides 190 entries wider than a /64. In 1,754 others it lists only the exits themselves, a single address in 1,619 of them, and the rest of the /64 isn't a VPN: in the six of those we tried, another address in the /64 came back `is_vpn: false`.

So the lookup needs no help from you. Mullvad's London server `2a03:1b20:7:f011::a01f`, from [Mullvad's own list](https://mullvad.net/en/servers), answers `is_vpn: true`, and so does any other address in its /64, while the /64 after it doesn't, as the run below shows.

## Count and ban by /64

A count by address doesn't hold on IPv6. A home connection is given at least a /64, and its devices move around in it; the busiest VPN /64s above hold thousands of exits each; and NyxProxy picks a new address from its /64 for every request. Each new address is a new visitor to a rate limit keyed by the address, and a ban on one lasts until the next.

[express-rate-limit](https://express-rate-limit.mintlify.app/reference/configuration), the usual rate limiter for Express, counts IPv6 by network since version 8, from July 2025. Its `ipv6Subnet` option sets the prefix, and `ipKeyGenerator` gives you the same key for a ban list or anything else you count. Its default is 56, which its docs call "a moderately aggressive default": a /56 holds 256 /64s. We use 64, the network one connection is given at the least.

## One address, several spellings

A Node.js server listening on every interface sees an IPv4 visitor as `::ffff:141.98.252.67`, and an IPv6 address has more than one spelling: `2A03:1B20:0007:F011:0:0:0:A01F` is `2a03:1b20:7:f011::a01f`. Our API and SDKs read `::ffff:141.98.252.67` as `141.98.252.67`, but a ban list that compares strings counts each pair as two visitors. `ipKeyGenerator` takes the IPv4 address out of the first and writes every IPv6 key in lowercase, compressed, so both spellings get one key.

## The code

Here it is in Express 5.3.0, with express-rate-limit 8.7.1 and our [Node.js SDK](https://docs.vpndetection.io/integrations/nodejs), `vpndetection` 5.6.0, on Node.js 22.22:

```bash
npm install express express-rate-limit vpndetection
```

```js
// app.js
import express from 'express';
import { rateLimit, ipKeyGenerator } from 'express-rate-limit';
import { VPNDetection } from 'vpndetection';

const client = new VPNDetection({ apiKey: process.env.VPNDETECTION_API_KEY });

const app = express();
// req.ip comes from X-Forwarded-For only when the proxy is on this machine.
app.set('trust proxy', 'loopback');

// Three signups an hour from one IPv4 address or one IPv6 /64.
const signupLimit = rateLimit({
    windowMs: 60 * 60 * 1000,
    limit: 3,
    ipv6Subnet: 64,
});

app.post('/signup', signupLimit, async (req, res) => {
    const key = ipKeyGenerator(req.ip, 64);
    try {
        // Look up the address itself, never the /64.
        const result = await client.lookup(req.ip);
        res.json({ ip: req.ip, key, vpn: result.isVpn });
    } catch {
        res.json({ ip: req.ip, key, vpn: null });
    }
});

app.listen(3000, '127.0.0.1');
```

The limiter refuses a fourth signup within the hour from the same IPv4 address or IPv6 /64, and the lookup gets the visitor's own address. A failed lookup lets the signup through with `vpn` as `null`. `trust proxy` set to `'loopback'` reads `req.ip` from `X-Forwarded-For` only when the request came from a proxy on the same machine, such as nginx; [our Express post](https://vpndetection.io/blog/detect-vpn-users-express-js) covers the other setups.

We ran it on October 18, 2026, with a key on the Max plan, sending each visitor's address in `X-Forwarded-For` as nginx would:

```sh
for ip in 2a03:1b20:7:f011::a01f 2a03:1b20:7:f012::1 ::ffff:141.98.252.67 \
          2a03:1b20:7:f011::1234 2a03:1b20:7:f011:abcd::1 2a03:1b20:7:f011::5; do
    curl -s -X POST http://127.0.0.1:3000/signup \
        -H "X-Forwarded-For: $ip" -w " %{http_code}\n"
done
```

```
{"ip":"2a03:1b20:7:f011::a01f","key":"2a03:1b20:7:f011::/64","vpn":true} 200
{"ip":"2a03:1b20:7:f012::1","key":"2a03:1b20:7:f012::/64","vpn":false} 200
{"ip":"::ffff:141.98.252.67","key":"141.98.252.67","vpn":true} 200
{"ip":"2a03:1b20:7:f011::1234","key":"2a03:1b20:7:f011::/64","vpn":true} 200
{"ip":"2a03:1b20:7:f011:abcd::1","key":"2a03:1b20:7:f011::/64","vpn":true} 200
Too many requests, please try again later. 429
```

The Mullvad server and two other addresses in its /64 came back as a VPN, and the /64 next to it didn't. `::ffff:141.98.252.67` kept its prefix in `req.ip`, and the key and the lookup both read it as Mullvad's London server on IPv4. The fourth signup from Mullvad's /64 was refused before any lookup, from an address the app hadn't seen. Two addresses in one home line's /64, which we don't print, came back `"vpn":false` under one key. With no key, the output was the same: every lookup answers `is_vpn`, with a key or without ([pricing](https://vpndetection.io/pricing)).

## A VPN that carries only IPv4

Yejin Cho and John Heidemann looked at 129,000 VPN-using daily visitors to WhatIsMyIPAddress.com in ["Smoothing Rough Edges of IPv6 in VPNs"](https://arxiv.org/abs/2512.19698) (November 2025). VPNs that claim only IPv4 support left the visitor's own IPv6 address exposed for "5% to 57% of visitors", and most users of VPNs that do carry IPv6 still connected over IPv4. On a site that answers on IPv6, that first visitor arrives from their ISP's address, and the lookup answers it as not a VPN. That's right: the address is theirs, and so is its location.

## Storing the address

`VARCHAR(15)` holds an IPv4 address and no IPv6 one, which takes up to 39 characters, or 45 with an IPv4 address written into it. Postgres's `inet` type stores both and compares them as addresses, whatever the spelling.

Look up any address, IPv4 or IPv6, with the [API](https://vpndetection.io/api), or read every field in the [docs](https://docs.vpndetection.io). VPN IP also comes as a [database](https://vpndetection.io/databases/vpn-ip), for lookups on your own servers.

