VPN & Proxy Detection for Online Games (Node.js Guide)

javascript dev.to

Detecting VPN and proxy players in an online game comes down to one uncomfortable fact: your server only ever sees an IP address. Not an account's real country, not the payment card's region, not whether the person behind it is sitting where they claim. Just an IP, and whatever you can infer from it.

That gap is worth money. Global games revenue crossed $200 billion in 2025 (Newzoo, up 9.1% year over year), and a real slice of that turns on regional pricing, staggered launches, and licensing that VPNs quietly route around. Add ranked lobbies degraded by region-hoppers and banned accounts coming back on a fresh IP, and detection stops being optional.

TL;DR

  • The server only sees an IP. IP intelligence turns that into VPN, proxy, Tor, and relay flags plus a 0 to 100 threat score.
  • It catches commercial VPNs and datacenter traffic well, catches residential proxies partially, and cannot see account or store region at all.
  • Check at login, matchmaking, and purchase. Not per frame. Cache the result briefly.
  • Do not treat iCloud Private Relay as a VPN. That punishes legitimate Apple and mobile players.
  • Fail open for gameplay so an API blip never locks out paying players. Fail closed only for purchases.

IP-based detection is one strong layer, not the whole answer. This walks through the Node.js you would actually ship for it, and it is honest about the cases where the IP tells you nothing useful.

What region-hoppers actually bypass

There are three separate things players evade, and they need different defenses.

The first is the storefront region: buying a game or edition cheaper by switching a Steam, PSN, or Xbox marketplace country, often with a matching billing address. The second is the game region or matchmaking pool: connecting to EU servers from another continent to squad up with friends, which quietly wrecks ping for everyone else in the match. The third is ban evasion: a banned account returning on a new IP.

IP data touches the second and third directly. It touches the first barely, because store region is usually tied to account and payment, not the IP at checkout. Knowing which problem you are solving keeps you from expecting the IP to fix something it never could.

What IP data catches, and what it can't

Be clear-eyed about this before you write a line of code, because overselling it is how you end up hard-blocking innocent players.

IP intelligence is strong on commercial VPNs and datacenter traffic. Those exit nodes live on known hosting ranges, get enumerated continuously, and often come with a provider name attached. If someone routes through a popular VPN, you will usually know, and you will frequently know which one.

It is partial on residential and mobile proxies. Those route through real consumer ISP connections, so the underlying IP looks like a normal home or carrier address. Good providers flag a meaningful share of them through behavioral signals and honeypot data, but nobody catches all of it, and the gap widens as proxy networks get better. Treat a clean residential result as "probably fine," not "definitely a real local player."

And it is blind to a whole category. The IP cannot tell you the account's store region, the billing country, or that a player borrowed a friend's home connection in the target country. If the enforcement problem lives at the account or payment layer, IP data is the wrong tool and no amount of code changes that.

So the honest framing: use IP signals to raise or lower confidence and add friction, and pair them with account age, payment region, and device signals for anything that actually costs the player something.

Reading the VPN and proxy signals in Node.js

The mechanics are simple. Send the player's IP to an IP intelligence endpoint, read the flags, decide what to do. The parts that separate a shipped feature from a snippet are the timeout, the fallback, the header handling, and knowing which flag means what.

I'll use ipgeolocation.io's IP Security API for the examples because a single call returns location and the full security object together, which is exactly what a region-plus-VPN check needs. Sign up for a key, or swap in whatever you already run. IPGeolocation, IPQualityScore, proxycheck, vpnapi, and MaxMind all return roughly the same shape; pick on residential-proxy coverage, whether you get provider names, and pricing model. Set the key as an environment variable, never inline.

The call, and the full response

Here is the raw request. Security data rides the unified endpoint with include=security, so one call gets you both region and risk. Note the API key travels in the query string, so keep full request URLs out of your logs.

curl -G 'https://api.ipgeolocation.io/v3/ipgeo' \
  --data-urlencode "apiKey=$IPGEO_API_KEY" \
  --data-urlencode 'ip=2.56.188.34' \
  --data-urlencode 'include=security'
Enter fullscreen mode Exit fullscreen mode

The response nests everything under typed objects. location gives you country and region, asn tells you who owns the network, and security carries the anonymizer signals. This is the complete security object, not a trimmed version, because the provider names, confidence scores, and last-seen dates are the parts that make a real enforcement decision possible.

{"ip":"2.56.188.34","location":{"country_code2":"NL","country_name":"Netherlands","state_prov":"North Holland","city":"Amsterdam","latitude":"52.37403","longitude":"4.88969","is_eu":true},"asn":{"as_number":"AS9009","organization":"M247 Europe SRL","type":"HOSTING"},"security":{"threat_score":78,"is_vpn":true,"vpn_provider_names":["NordVPN"],"vpn_confidence_score":90,"vpn_last_seen":"2026-07-13","is_proxy":false,"proxy_provider_names":[],"proxy_confidence_score":0,"proxy_last_seen":null,"is_tor":false,"is_anonymous":true,"is_residential_proxy":false,"is_known_attacker":false,"is_bot":false,"is_spam":false,"is_relay":false,"relay_provider_name":null,"is_cloud_provider":true,"cloud_provider_name":"M247"}}
Enter fullscreen mode Exit fullscreen mode

Two fields do most of the work for games. asn.type returning HOSTING is a strong tell on its own, because home players do not connect from datacenter ranges. And is_relay, which you will meet again below, is the one flag people misread most often.

A lookup with a timeout and a fallback

Wrap the call so a slow or failed API never blocks a login. Node 18+ ships a global fetch, so there are no dependencies here beyond the runtime.

// ip-intel.js
// One lookup, hard timeout, and a safe shape on failure.

const IPGEO_URL = "https://api.ipgeolocation.io/v3/ipgeo";

export async function lookupIp(ip) {
  const key = process.env.IPGEO_API_KEY;
  if (!key || !ip) return null; // No key or no IP: caller decides what to do.

  const params = new URLSearchParams({ apiKey: key, ip, include: "security" });

  try {
    const res = await fetch(`${IPGEO_URL}?${params}`, {
      // 1.5s ceiling. A login flow cannot wait on a slow network call.
      signal: AbortSignal.timeout(1500),
    });

    if (!res.ok) {
      // 401 usually means the security module is not on your plan.
      console.error(`ip lookup failed: HTTP ${res.status}`);
      return null;
    }

    const data = await res.json();
    const sec = data.security ?? {};

    return {
      country: data.location?.country_code2 ?? null,
      asnType: data.asn?.type ?? null,
      // threat_score can serialize as a number or a numeric string. Coerce once.
      threatScore: Number(sec.threat_score ?? 0),
      isVpn: sec.is_vpn === true,
      isProxy: sec.is_proxy === true,
      isResidentialProxy: sec.is_residential_proxy === true,
      isTor: sec.is_tor === true,
      isRelay: sec.is_relay === true,
      vpnProvider: sec.vpn_provider_names?.[0] ?? null,
    };
  } catch (err) {
    // Timeout or network error. Return null and let the caller fail open.
    console.error(`ip lookup error: ${err.name}`);
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

The null return is deliberate. The lookup does not decide policy; it reports what it found or admits it found nothing, and the caller chooses how to react. That separation is what lets you fail open in one place and fail closed in another.

Login and matchmaking middleware

Now the part Greip-style guides describe but rarely show: a real Express middleware. The trap here is the client IP. Behind a load balancer or CDN, req.socket.remoteAddress is the proxy, not the player, and reading x-forwarded-for blindly lets a client spoof whatever IP it wants. Configure trust proxy for your actual hop count or trusted subnet, then read req.ip.

// server.js
import express from "express";
import { lookupIp } from "./ip-intel.js";

const app = express();

// Set this to your real infrastructure: the number of proxies in front of
// the app, or the trusted subnet. Blindly trusting all forwarded headers is
// how attackers feed you a fake IP.
app.set("trust proxy", 1);

async function checkConnection(req, res, next) {
  const intel = await lookupIp(req.ip);

  // Fail OPEN for gameplay. An API blip must not lock out paying players.
  // Log it so a sustained outage is visible, then let them through.
  if (!intel) {
    console.warn(`ip intel unavailable for ${req.ip}; allowing`);
    req.playerRisk = { level: "unknown", reason: "lookup_failed" };
    return next();
  }

  // Datacenter or a flagged VPN/proxy is the high-signal case for games.
  const anonymized =
    intel.isVpn || intel.isProxy || intel.isResidentialProxy ||
    intel.isTor || intel.asnType === "HOSTING";

  req.playerRisk = {
    level: intel.threatScore >= 45 || anonymized ? "elevated" : "normal",
    score: intel.threatScore,
    country: intel.country,
    vpnProvider: intel.vpnProvider,
    isRelay: intel.isRelay,
  };

  next();
}

// Attach to the routes where a check is worth the latency: login,
// matchmaking, store. Never on a per-frame or per-tick path.
app.post("/login", checkConnection, (req, res) => {
  // Read req.playerRisk downstream to route the session. See enforcement below.
  res.json({ ok: true, risk: req.playerRisk.level });
});

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

Two calls, one middleware, and the risk verdict rides along on the request for the route handler to act on. Notice there is no hard block in here. That decision belongs in enforcement, where it can differ by action.

Why iCloud Private Relay is not a VPN

This is the mistake worth catching before it ships. iCloud Private Relay and similar privacy relays route a real person's traffic through Apple's infrastructure, so the exit IP is not the player's own. An API that lumps relays in with VPNs will flag a large, growing slice of ordinary iPhone and Mac players as evaders.

That is why the response separates is_relay from is_vpn. A relay hit is not a region-hopper signal; it is a privacy-feature signal from a legitimate device. In the middleware above, is_relay is carried through but deliberately left out of the anonymized check, so a relay user is not swept into the elevated bucket. If you want to be stricter, treat a relay as "verify by other means," not "block." Blocking Apple's relay is a support-ticket generator, not a fair-play win.

From detection to enforcement

Binary "block all VPNs" is the blunt instrument that gets you false-positive complaints from corporate, university, and carrier-CGNAT players who share flagged ranges. Score the connection instead and match the response to the stakes. The threat_score gives you a 0 to 100 dial; here is a starting map for games.

threat_score What it usually means Gaming action
0 to 19 Clean residential or mobile IP Allow everything, including ranked
20 to 44 Some signal, needs context Allow, log, watch for other flags
45 to 79 Flagged VPN, proxy, or datacenter Allow casual, restrict ranked and store
80 to 100 Tor, known attacker, high-confidence proxy Block or send to manual review

The bands are a default, not gospel. Tune them against your own traffic, and remember that a threat score is a starting point, not a verdict; read the individual flags to see why an IP scored the way it did before you act on it. A datacenter VPN and a residential proxy at the same score deserve different treatment, and the flags are what tell them apart.

Where you enforce also matters more than the number. At login, restrict ranked for elevated risk but let casual through. At matchmaking, use the resolved country to keep a VPN player in their true regional pool instead of the one they are pretending to be in. At purchase, this is the one place fail-closed makes sense: if the flags say VPN and the store region does not match, hold the transaction and ask the player to buy from their real region.

Where this breaks, honestly

A few things this approach does not solve, so you plan around them instead of trusting the check too far.

Residential proxies are the hard problem. A player routing through a residential-proxy service looks like a real home connection, and is_residential_proxy will catch some but not all of them. Pair it with account age and behavior for anything high-stakes; do not rely on the IP alone.

A borrowed IP defeats you cleanly. Someone playing through a friend's home connection in the target country produces a genuinely local, genuinely clean result. There is no IP signal that flags this, because there is nothing anomalous about the IP. This is an account-layer problem.

Store-region abuse mostly lives outside the IP. Because storefront region is tied to account and payment method, an IP check at checkout is a weak backstop at best. The real controls are billing-country verification and account region history.

And false positives are real. Corporate networks, university campuses, and mobile carriers using CGNAT can share IP ranges that carry VPN or proxy signals. That is exactly why the enforcement above leans on soft friction and fail-open defaults rather than hard blocks: the cost of wrongly locking out a paying player is higher than the cost of letting one region-hopper into a casual match.

Wire the check into login, matchmaking, and purchase, cache each IP's result for five to ten minutes so you are not paying for a lookup on every request, and read the individual flags before you act rather than trusting the score alone. If I were building this from scratch, I would fail open everywhere except the payment path from day one, and treat every flag as a reason to add friction, not a reason to slam the door.

Source: dev.to

arrow_back Back to Tutorials