Detect VPN and proxy users in JavaScript and Node.js (free API, no key)
Detect VPN, proxy, Tor and data center visitors in Express and Next.js with one free API call, tested on 80 public addresses, and why browser tricks fail.
To detect VPN and proxy users in JavaScript, check the visitor's IP address on your server. There the address is known, and the visitor cannot change the code. Ask a free IP API about the address and act on its risk band, with the flags as the reason. FFraud's endpoint needs no key, and in our test it flagged 20 of 20 Tor exits, VPN servers and iCloud Private Relay addresses for what they were.
#Why can't the browser detect a VPN by itself?
Browser code runs on the visitor's machine, so the visitor can read it, change it or switch it off. Three browser tricks show up in tutorials, and each one is weak:
- The time zone.
Intl.DateTimeFormat().resolvedOptions().timeZonereturns the device's own time zone (MDN). It differs from the IP's country for every traveler, and a VPN user can change it. - WebRTC. Without the user's consent, browsers should gather addresses only along the default route (RFC 8828, Mode 2). With a full-tunnel VPN that route runs through the VPN, so WebRTC shows the VPN's address. The RFC names split-tunnel VPNs as the case where the address behind the VPN leaked.
- The address itself. A page cannot read its own public IP address without asking a server, and that server can run the check instead.
FFraud's API answers browser requests too, since it sends Access-Control-Allow-Origin: *. That is fine for showing visitors their own result. Make the decision on the server.
#How do you check the address on the server?
// Detect VPN, proxy, Tor and data center visitors in Node.js with FFraud's free IP API. No key, no packages.
const API = "https://api.ffraud.com/public/ip/";
const cache = new Map(); // address -> { at, answer }, kept 5 minutes
// The API's answer for one address, or null when it cannot answer in time.
export async function ipCheck(ip, timeoutMs = 2000) {
if (!ip) return null;
const hit = cache.get(ip);
if (hit && Date.now() - hit.at < 5 * 60 * 1000) return hit.answer;
try {
const res = await fetch(API + encodeURIComponent(ip), { signal: AbortSignal.timeout(timeoutMs) });
if (!res.ok) return null;
const answer = await res.json();
if (cache.size > 10000) cache.clear();
cache.set(ip, { at: Date.now(), answer });
return answer;
} catch {
return null;
}
}
// What to do with the request, by FFraud's risk bands; the flags say why.
export function verdict(a) {
if (!a) return { action: "allow", why: "IP check unavailable" };
const why = a.tor ? "Tor exit"
: a.vpn ? `VPN${a.vpn_provider ? ` (${a.vpn_provider})` : ""}`
: a.proxy ? "proxy"
: a.relay ? "privacy relay"
: a.hosting ? `data center${a.cloud_provider ? ` (${a.cloud_provider})` : ""}`
: "no flag";
if (a.risk === "critical" || (a.risk === "high" && a.recent_abuse)) return { action: "block", why, risk: a.risk };
if (a.risk === "high" || a.risk === "medium") return { action: "challenge", why, risk: a.risk };
return { action: "allow", why, risk: a.risk };
}- One call, no key.
ipCheckasks/public/ip/{ip}and waits 2 seconds at most. Without an answer it returns null, andverdictlets the request through. - Five minutes of memory. The API marks its answer cacheable for 5 minutes (
Cache-Control: max-age=300, October 26, 2026), and the module keeps it as long. It empties the cache once it holds 10,000 addresses. - The band decides, the flags explain.
verdictfollows FFraud's bands: block a critical score or a high one with recent abuse, challenge medium and high, allow the rest (FFraud docs). The flags give the reason for your log.
#What does the API say about VPN, Tor and relay addresses?
We drew 20 addresses each from four public lists on October 26, 2026, and asked the free API about all 80, one call a second:
- Tor exits20 of 20
- PIA VPN servers20 of 20
- AWS EC2 addresses1 of 20
- iCloud Private Relay0 of 20
Source: FFraud free API; addresses from the Tor Project, PIA, Amazon and Apple lists, October 26, 2026
| List | Flags set | Risk bands | Operator named |
|---|---|---|---|
| Tor exits | tor 20, hosting 20, vpn 9 | critical 20 | Tor, 5 |
| PIA VPN servers | vpn 20, hosting 19, proxy 3 | medium 16, high 4 | Private Internet Access, 20 |
| iCloud Private Relay | relay 20, hosting 20, vpn 1 | low 15, none 5 | Cloudflare, 15 |
| AWS EC2 | hosting 20, vpn 1 | none 17, low 2, medium 1 | Amazon Web Services, 20 |
79 of the 80 addresses came back hosting, so a rule that blocks data centers also blocks iCloud Private Relay. Apple says each Private Relay connection is checked for an Apple device and an iCloud+ subscription, and that IP-based fraud checks may need adjusting (Apple developer docs). The bands already let it through.
Four of the 20 PIA servers scored high, and one AWS address medium, so read the band on every call. Our sample came from published lists, the easy case; an address no list holds yet can still pass.
#How do you add it to Express and Next.js?
import { ipCheck, verdict } from "./ip-check.mjs";
// app.set("trust proxy", ...) to the proxies you run, so req.ip is the visitor; then
// app.post("/signup", ipGuard(), handler). A "challenge" verdict is left to the handler.
export function ipGuard() {
return async (req, res, next) => {
req.ipVerdict = verdict(await ipCheck(req.ip));
if (req.ipVerdict.action === "block") return res.status(403).json({ error: "Requests from this network are not accepted." });
next();
};
}Set trust proxy to the proxies you run. Express then takes req.ip from X-Forwarded-For, starting at the first untrusted address (Express docs). Our
import { ipCheck, verdict } from "../../../lib/ip-check.mjs";
export async function POST(request) {
// On Vercel, and behind your own nginx, x-real-ip holds the visitor's address.
const v = verdict(await ipCheck(request.headers.get("x-real-ip")));
if (v.action === "block") return Response.json({ error: "Requests from this network are not accepted." }, { status: 403 });
// A "challenge": ask for a CAPTCHA or a confirmed email before the account goes live.
// create the account here
return Response.json({ ok: true, challenge: v.action === "challenge" });
}On Vercel, x-real-ip carries the client's public address, and Vercel overwrites X-Forwarded-For instead of passing on outside values (Vercel docs). Self-hosted, have your own proxy write the header. A challenge can be a CAPTCHA or an email confirmation before the account goes live.
#How do you test it?
We ran the module and both entry points on Node.js 22 on October 26, 2026. Express and Next.js were not installed: the middleware got stand-in req and res objects, and the route handler a real Web Request:
Node v22.19.0 2026-10-26T01:22:41.468Z
45.66.35.28 Tor exit risk critical flags: tor, hosting -> {"action":"block","why":"Tor exit","risk":"critical"} 633 ms, API calls: 1
104.28.80.101 iCloud Private Relay risk low flags: relay, hosting -> {"action":"allow","why":"privacy relay","risk":"low"} 148 ms, API calls: 1
62.169.130.76 PIA VPN server risk medium flags: vpn, hosting -> {"action":"challenge","why":"VPN (Private Internet Access)","risk":"medium"} 130 ms, API calls: 1
15.193.8.246 AWS EC2 risk none flags: hosting -> {"action":"allow","why":"data center (Amazon Web Services)","risk":"none"} 558 ms, API calls: 1
8.8.8.8 Google DNS risk none flags: hosting -> {"action":"allow","why":"data center (Google Cloud)","risk":"none"} 225 ms, API calls: 1
8.8.8.8 again within 5 minutes: API calls: 0
express, Tor exit: {"status":403,"json":{"error":"Requests from this network are not accepted."},"nextCalled":false,"ipVerdict":{"action":"block","why":"Tor exit","risk":"critical"}}
express, iCloud Private Relay: {"nextCalled":true,"ipVerdict":{"action":"allow","why":"privacy relay","risk":"low"}}
route POST, PIA VPN server -> 200 {"ok":true,"challenge":true}
route POST, Tor exit -> 403 {"error":"Requests from this network are not accepted."}
route POST, AWS EC2 -> 200 {"ok":true,"challenge":false}
route POST, no x-real-ip header -> 200 {"ok":true,"challenge":false}
API down, 203.0.113.9: {"action":"allow","why":"IP check unavailable"}After the first lookup of each address, the Express and route cases read the cache and made no new API calls. Run the route once inside your own app before you ship it.
#Questions people ask
How do I detect if a user is using a VPN in JavaScript?
Check the visitor's IP address on your server with an IP API, and act on its risk band. Browser code can be switched off, and it cannot see the public address.
Can JavaScript detect a VPN without an API?
Only weakly. A time zone that differs from the IP's country hints at a VPN, but travelers trip it and VPN users can change it. Matching the address against VPN ranges needs a list someone keeps current, which is what an API does.
Does WebRTC reveal the real IP behind a VPN?
Rarely now. Without consent, RFC 8828 has browsers follow the default route, and with a full-tunnel VPN that route goes through the VPN.
Will blocking VPNs block iCloud Private Relay users?
It will if you block data centers or every relay. The free API marks Private Relay as relay and scores it low or none, and the verdict above lets it through.
How accurate is VPN detection by IP address?
In our test the free API flagged all 60 Tor, VPN and relay addresses we sampled for what they were. Those came from published lists, so a clean answer means no evidence was found.
Should VPN detection run in the browser or on the server?
On the server. It sees the connecting address, and the visitor cannot change its code.
To look up one address by hand, use the free