DEV Community

Cover image for How to build reliable geo-restrictions that actually hold up in production
Alan West
Alan West

Posted on

How to build reliable geo-restrictions that actually hold up in production

Last week I saw another platform get blocked in a European market because their geo-restriction setup was, charitably, optimistic. A single header check. No IP verification. Nothing to handle VPNs or the weird middle-ground of corporate proxies. The result? Regulators noticed users in restricted regions were still getting through, and the whole product got pulled.

I've shipped jurisdiction-based access controls for a fintech and a streaming-adjacent product, and I'll tell you up front: this stuff is harder than it looks. The problem isn't "detect the country" — that part has been solved for years. The problem is doing it reliably enough that compliance won't yell at you, without breaking your legitimate users in the process.

Let's walk through what actually goes wrong and how to build something that holds up.

Why naive geo-blocking fails

Most teams start with something like this:

// Don't do this in production
app.use((req, res, next) => {
  const country = req.headers['cf-ipcountry']; // or similar
  if (BLOCKED_COUNTRIES.includes(country)) {
    return res.status(451).send('Not available in your region');
  }
  next();
});
Enter fullscreen mode Exit fullscreen mode

Looks fine. Ships in five minutes. And it falls apart roughly the moment a real user hits it. Here's what I've seen break:

  • IP geolocation is probabilistic, not deterministic. Free databases are accurate maybe 95-97% at the country level. The remaining few percent is users hitting your block page in the wrong country, or the right country thinking they're somewhere else.
  • Mobile carriers route weirdly. A user in Madrid on a mobile network might appear to come from a carrier hub in Frankfurt. I've seen this trip up half a dozen geo checks.
  • CDN headers can be cached. If you're caching responses upstream of your geo check, you can serve a Spanish user a German-cached page. Whoops.
  • VPN and residential proxy traffic. This is the big one. If you only check IP, you're trivially bypassed.

The root cause across all of these is that we're trying to make a binary decision (allow/deny) based on a fundamentally fuzzy signal (where is this packet coming from). The fix is to stop pretending the signal is clean.

A layered approach that actually works

The pattern I've landed on after migrating three projects is: multiple independent signals, scored, with explicit handling for ambiguous cases. Not a single check.

Here's the rough shape of the middleware I use. Adapt to your stack — this is Node, but the logic ports cleanly:

const geoip = require('geoip-lite'); // open-source, MaxMind GeoLite2 data

async function evaluateJurisdiction(req) {
  const signals = {
    ipCountry: null,
    headerCountry: null,
    declaredCountry: null,
    vpnLikely: false,
  };

  // Signal 1: server-side IP lookup (don't trust client headers alone)
  const ip = getClientIp(req); // see notes below on extracting this safely
  const lookup = geoip.lookup(ip);
  signals.ipCountry = lookup?.country || null;

  // Signal 2: edge/CDN-provided country header, if you have one
  // Useful as a cross-check, NOT as your only source
  signals.headerCountry = req.headers['x-edge-country'] || null;

  // Signal 3: account-declared jurisdiction from signup/KYC
  // This is the most reliable for authenticated users
  signals.declaredCountry = req.user?.declaredCountry || null;

  // Signal 4: VPN/proxy heuristics — ASN-based, since residential proxies
  // are detectable by the ASNs they route through
  signals.vpnLikely = await checkAsnAgainstVpnList(ip);

  return signals;
}
Enter fullscreen mode Exit fullscreen mode

The key idea: collect signals first, decide second. The decision logic then looks something like:

function shouldBlock(signals, restrictedCountries) {
  // Authenticated user with a declared country we restrict? Always block.
  // This is the strongest signal — they told us themselves.
  if (signals.declaredCountry && restrictedCountries.includes(signals.declaredCountry)) {
    return { block: true, reason: 'declared_jurisdiction' };
  }

  // Two independent network signals agree on a restricted country? Block.
  const networkAgrees =
    signals.ipCountry === signals.headerCountry &&
    restrictedCountries.includes(signals.ipCountry);
  if (networkAgrees) {
    return { block: true, reason: 'ip_and_edge_agree' };
  }

  // VPN detected from a country we restrict, OR VPN with no other signal?
  // Force re-verification rather than silently allowing or blocking.
  if (signals.vpnLikely) {
    return { block: false, requireVerification: true, reason: 'vpn_detected' };
  }

  // IP says restricted but no corroborating signal — soft block with appeal path
  if (restrictedCountries.includes(signals.ipCountry)) {
    return { block: true, reason: 'ip_only', appealable: true };
  }

  return { block: false };
}
Enter fullscreen mode Exit fullscreen mode

Notice what this does that the naive version doesn't:

  • It distinguishes between "definitely restricted" and "probably restricted"
  • It gives users a path to appeal a false positive
  • It treats VPN traffic as a signal to verify, not silently allow
  • It uses the user's own declared jurisdiction as the strongest signal when available

Getting the client IP right

One footgun worth calling out: extracting the actual client IP. If you naively use req.ip behind a load balancer or CDN, you might get the proxy's IP. And if you blindly trust X-Forwarded-For, anyone can spoof it.

The pattern:

function getClientIp(req) {
  // X-Forwarded-For is a chain: client, proxy1, proxy2, ...
  // Trust only the rightmost N entries where N = number of YOUR trusted proxies
  const forwarded = req.headers['x-forwarded-for'];
  if (!forwarded) return req.socket.remoteAddress;

  const chain = forwarded.split(',').map(s => s.trim());
  const trustedProxyCount = 1; // however many proxies sit in front of you
  const clientIndex = chain.length - 1 - trustedProxyCount;
  return chain[Math.max(0, clientIndex)] || req.socket.remoteAddress;
}
Enter fullscreen mode Exit fullscreen mode

If you're on Express, use app.set('trust proxy', N) with the right number — the docs at expressjs.com cover this in more detail than I will here. The mistake I see most often is trust proxy: true, which trusts everything in the chain and is exploitable.

Prevention: things to bake in from day one

A few things I wish I'd done sooner:

  • Log every blocked request with the signal breakdown. When compliance asks "how do you verify users aren't in X?", you want auditable logs, not just "we have a check."
  • Build the appeal flow before you need it. Some percentage of your blocks will be wrong. Users hate hitting a wall with no recourse. A simple "verify with ID" path solves the majority of false positives.
  • Keep your GeoIP database current. MaxMind's GeoLite2 ships updates twice a week. If yours is six months stale, your accuracy is degrading silently.
  • Test with VPNs as part of CI. I have a smoke test that hits the staging environment through a few known VPN exit nodes. Catches regressions in the VPN detection logic faster than waiting for a user report.

The broader lesson: any time you're making a regulatory decision based on a network signal, you should assume the signal is partially wrong, partially gameable, and occasionally cached. Build for that, not against it. Your future self — and your compliance team — will thank you.

Top comments (0)