On this page
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 in it as they go, and NyxProxy, 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 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, 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, 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, vpndetection 5.6.0, on Node.js 22.22:
npm install express express-rate-limit vpndetection// 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 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:
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).
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" (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, or read every field in the docs. VPN IP also comes as a database, for lookups on your own servers.


