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().timeZone returns 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?

lib/ip-check.mjs
// 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. ipCheck asks /public/ip/{ip} and waits 2 seconds at most. Without an answer it returns null, and verdict lets 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. verdict follows 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:

Addresses the free API would challenge or blockOut of 20 addresses drawn from each public list, scored medium, high or critical, October 26, 2026
  1. Tor exits20 of 20
  2. PIA VPN servers20 of 20
  3. AWS EC2 addresses1 of 20
  4. iCloud Private Relay0 of 20

Source: FFraud free API; addresses from the Tor Project, PIA, Amazon and Apple lists, October 26, 2026

ListFlags setRisk bandsOperator named
Tor exitstor 20, hosting 20, vpn 9critical 20Tor, 5
PIA VPN serversvpn 20, hosting 19, proxy 3medium 16, high 4Private Internet Access, 20
iCloud Private Relayrelay 20, hosting 20, vpn 1low 15, none 5Cloudflare, 15
AWS EC2hosting 20, vpn 1none 17, low 2, medium 1Amazon 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?

lib/express-ip-check.mjs
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 X-Forwarded-For guide gives the value for common setups.

app/api/signup/route.js
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:

test run, October 26, 2026, 01:22 UTC
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 proxy and VPN check or the IP lookup. Every field of the answer is listed in the IP intelligence docs. What is my ISP shows how sites classify your own connection.

Sources

  1. IP intelligence, FFraud docs
  2. RFC 8828: WebRTC IP Address Handling Requirements (January 2021), IETF
  3. Intl.DateTimeFormat.prototype.resolvedOptions(), MDN Web Docs
  4. Express behind proxies, Express documentation
  5. Request headers, Vercel docs
  6. Prepare your network or web server for iCloud Private Relay, Apple Developer
  7. Tor bulk exit list, The Tor Project, October 26, 2026
  8. AWS IP address ranges, Amazon VPC User Guide
  9. Server list, version 6, Private Internet Access, October 26, 2026