DEV Community

Ishaan Pandey
Ishaan Pandey

Posted on • Originally published at ishaaan.hashnode.dev

Geofencing & Geolocking in Applications: The Complete Guide

Geofencing & Geolocking in Applications: The Complete Guide

Ever tried watching a show on Netflix while traveling and got hit with "This content is not available in your region"? That's geolocking in action. And if you've ever gotten a push notification from Starbucks the moment you walked past one — that's geofencing.

These two concepts sound similar but serve very different purposes. In this guide, we'll break down both, explore the tech behind them, and walk through real implementations — from IP lookups to coordinate math to CDN-level blocking.


Geofencing vs Geolocking: What's the Difference?

Let's clear this up right away because these terms get mixed up constantly.

Geofencing Geolocking
What it does Triggers an action when a user enters/exits a geographic area Restricts or allows access based on user location
Primary purpose Engagement, notifications, analytics Access control, compliance, licensing
Direction "You entered zone X, here's a coupon" "You're in country Y, you can't see this"
Typical precision GPS-level (meters) IP-level (country/region)
Examples Uber surge zones, retail push notifications, fleet tracking Netflix content libraries, GDPR compliance, regional pricing

Geofencing defines a virtual perimeter around a real-world area. When a device enters or leaves that perimeter, something happens — a notification fires, data gets logged, a workflow triggers.

Geolocking (also called geo-restriction or geo-blocking) restricts access to content or services based on the user's geographic location. It's a gatekeeper, not a trigger.

Think of it this way:

Geofencing:  "Welcome to the store! Here's 10% off."  (reactive)
Geolocking:  "Sorry, this store doesn't serve your country."  (restrictive)
Enter fullscreen mode Exit fullscreen mode

Why Would You Want Location-Based Restrictions?

There are more reasons than you'd think.

1. Content Licensing

This is the big one. Streaming platforms like Netflix, Spotify, and Disney+ license content on a per-region basis. A show available in the US might not be licensed for the UK. Geolocking enforces those contracts.

2. Regulatory Compliance

  • GDPR (EU): Certain data processing rules apply to EU residents
  • Data residency laws: Some countries (Russia, China, India) require user data to stay within their borders
  • Online gambling: Legal in some jurisdictions, heavily restricted in others
  • Financial services: Different regulations per country — you can't offer certain financial products everywhere

3. Regional Pricing

Software companies often adjust pricing by region. A $20/month subscription in the US might be $5/month in India. Without geolocking, everyone would just VPN to the cheapest region.

4. Security

Blocking traffic from regions where you have no customers can reduce your attack surface. If your SaaS only serves North America and Europe, traffic from unexpected regions might be worth flagging or blocking.

5. Feature Rollout

Rolling out a new feature? Test it in Canada first before going global. Geofencing lets you do staged rollouts by region without complex feature flag setups.

6. Marketing & Engagement

Retail chains use geofencing to send push notifications when customers are near a store. Ride-sharing apps define surge pricing zones. Event apps trigger check-ins when attendees arrive.


How Geolocation Actually Works

Before you can fence or lock anything, you need to know where the user is. There are several ways to figure that out, each with different tradeoffs.

IP-Based Geolocation

The most common server-side approach. Every device on the internet has an IP address, and databases map IP ranges to geographic locations.

User Request
    |
    v
[Your Server] --> Extract IP --> [GeoIP Database Lookup]
                                        |
                                        v
                                 Country: US
                                 Region: California
                                 City: San Francisco
                                 Lat/Long: 37.77, -122.42
Enter fullscreen mode Exit fullscreen mode

Accuracy:

  • Country level: ~99% accurate
  • State/Region level: ~80-90% accurate
  • City level: ~50-75% accurate
  • Exact coordinates: Not reliable at all

Limitations:

  • VPNs and proxies mask the real IP
  • Mobile networks can show the carrier's gateway, not the user's location
  • Corporate networks might route through a central office in another state
  • IPv6 adoption is still uneven in geolocation databases

GPS-Based Geolocation

The gold standard for precision (within a few meters). Works through the device's GPS hardware. Primarily used in mobile apps, but the browser Geolocation API can access it too.

Pros: Extremely accurate, works offline (on mobile)
Cons: Requires user permission, drains battery, doesn't work well indoors, not available server-side

WiFi Triangulation

By measuring signal strength from nearby WiFi access points and comparing against a database of known access point locations, you can estimate position to within 15-40 meters.

Pros: Works indoors, no GPS needed
Cons: Requires a database of access points, accuracy varies, urban areas only

Cell Tower Triangulation

Similar to WiFi but uses cell towers. Less precise (100m to several km) but works anywhere with cell service.

Browser Geolocation API

The browser's built-in API that combines multiple sources (GPS, WiFi, IP) to get the best possible location:

// Browser Geolocation API
navigator.geolocation.getCurrentPosition(
  (position) => {
    console.log('Latitude:', position.coords.latitude);
    console.log('Longitude:', position.coords.longitude);
    console.log('Accuracy:', position.coords.accuracy, 'meters');
  },
  (error) => {
    switch (error.code) {
      case error.PERMISSION_DENIED:
        console.log('User denied geolocation');
        break;
      case error.POSITION_UNAVAILABLE:
        console.log('Location info unavailable');
        break;
      case error.TIMEOUT:
        console.log('Request timed out');
        break;
    }
  },
  {
    enableHighAccuracy: true,  // Use GPS if available
    timeout: 10000,            // Wait up to 10 seconds
    maximumAge: 300000         // Accept cached position up to 5 minutes old
  }
);
Enter fullscreen mode Exit fullscreen mode

Important: This requires explicit user permission. The browser shows a permission prompt, and users can deny it. You cannot silently get GPS-level location in a browser.


IP Geolocation Deep Dive

Since IP geolocation is the backbone of most server-side geo-restriction, let's go deeper.

Major GeoIP Database Providers

Provider Free Tier Accuracy Update Frequency Notes
MaxMind GeoLite2 Yes (free) Good Biweekly Industry standard, requires account
MaxMind GeoIP2 Paid Better Daily Commercial version of GeoLite2
IP2Location Free (LITE) Good Monthly Also has paid tiers
ipinfo.io 50k req/mo free Good Continuous API-based, no local DB needed
DB-IP Free tier Decent Monthly Also has paid tiers
Cloudflare Included Good Continuous Only if you use Cloudflare

MaxMind GeoLite2 — The Industry Standard

MaxMind's free GeoLite2 database is what most people start with. It comes in two formats:

  • GeoLite2-City: Country, region, city, postal code, approximate coordinates
  • GeoLite2-Country: Just country-level data (smaller, faster)

The database is a binary .mmdb file you download and query locally — no API calls, no latency, no rate limits.

// Install: npm install maxmind
const maxmind = require('maxmind');

async function lookupIP(ip) {
  const lookup = await maxmind.open('/path/to/GeoLite2-City.mmdb');
  const result = lookup.get(ip);

  if (result) {
    return {
      country: result.country?.iso_code,           // "US"
      countryName: result.country?.names?.en,       // "United States"
      region: result.subdivisions?.[0]?.iso_code,   // "CA"
      city: result.city?.names?.en,                 // "San Francisco"
      postal: result.postal?.code,                  // "94102"
      latitude: result.location?.latitude,          // 37.7749
      longitude: result.location?.longitude,        // -122.4194
      accuracyRadius: result.location?.accuracy_radius, // 20 (km)
      timezone: result.location?.time_zone          // "America/Los_Angeles"
    };
  }
  return null;
}
Enter fullscreen mode Exit fullscreen mode

Accuracy Reality Check

People overestimate IP geolocation accuracy. Here's the truth:

Country Level:   ████████████████████░  ~99%   (very reliable)
State Level:     ████████████████░░░░░  ~80%   (usually right)
City Level:      ████████████░░░░░░░░░  ~60%   (hit or miss)
ZIP/Postal:      ████████░░░░░░░░░░░░░  ~40%   (don't rely on this)
Street Level:    ██░░░░░░░░░░░░░░░░░░░  ~10%   (basically useless)
Enter fullscreen mode Exit fullscreen mode

For geolocking (country-level), IP geolocation is perfectly fine. For geofencing (precise areas), you need GPS or the browser Geolocation API.

What Breaks IP Geolocation

  • VPNs: User appears to be wherever the VPN server is
  • Proxies/Tor: Similar to VPNs, masks real IP
  • Mobile carriers: A user in Dallas might route through a gateway in Houston
  • Corporate VPNs: Employee in Tokyo shows up as being in New York HQ
  • Satellite internet (Starlink): IP might geolocate to a ground station hundreds of miles away
  • CGN (Carrier-Grade NAT): Multiple users share one public IP, which might geolocate to the ISP's facility, not the user

Implementing Geolocking in a Backend

Let's get practical. Here are several approaches, from application-level to infrastructure-level.

Approach 1: Node.js/Express Middleware

The most flexible approach — you control everything in your application code.

// geolock.middleware.js
const maxmind = require('maxmind');
const path = require('path');

let geoLookup = null;

// Initialize the database once at startup
async function initGeoIP() {
  geoLookup = await maxmind.open(
    path.join(__dirname, '../data/GeoLite2-Country.mmdb')
  );
  console.log('GeoIP database loaded');
}

// Extract real IP behind proxies/load balancers
function getClientIP(req) {
  // Check common proxy headers (in order of trust)
  const forwarded = req.headers['x-forwarded-for'];
  if (forwarded) {
    // x-forwarded-for can be a comma-separated list
    // The first IP is the original client
    return forwarded.split(',')[0].trim();
  }

  // Cloudflare
  if (req.headers['cf-connecting-ip']) {
    return req.headers['cf-connecting-ip'];
  }

  // AWS ALB / ELB
  if (req.headers['x-real-ip']) {
    return req.headers['x-real-ip'];
  }

  return req.socket.remoteAddress;
}

// Middleware factory: pass allowed countries
function geolock(allowedCountries, options = {}) {
  const {
    blockMessage = 'This content is not available in your region.',
    blockStatusCode = 403,
    allowUnknown = false,  // What to do if we can't determine location
  } = options;

  return (req, res, next) => {
    if (!geoLookup) {
      console.error('GeoIP database not initialized');
      return next(); // Fail open — or fail closed depending on your policy
    }

    const ip = getClientIP(req);
    const result = geoLookup.get(ip);
    const country = result?.country?.iso_code;

    // Attach geo info to request for downstream use
    req.geo = {
      ip,
      country,
      countryName: result?.country?.names?.en
    };

    if (!country) {
      if (allowUnknown) return next();
      return res.status(blockStatusCode).json({
        error: blockMessage,
        code: 'GEO_UNKNOWN'
      });
    }

    if (!allowedCountries.includes(country)) {
      return res.status(blockStatusCode).json({
        error: blockMessage,
        code: 'GEO_BLOCKED',
        detectedCountry: country
      });
    }

    next();
  };
}

module.exports = { initGeoIP, geolock, getClientIP };
Enter fullscreen mode Exit fullscreen mode

Using the middleware:

const express = require('express');
const { initGeoIP, geolock } = require('./middleware/geolock');

const app = express();

// Initialize GeoIP before starting the server
initGeoIP().then(() => {

  // Block entire API to specific countries
  app.use('/api/v1', geolock(['US', 'CA', 'GB', 'DE', 'FR']));

  // Or per-route restrictions
  app.get('/api/content/us-only',
    geolock(['US']),
    (req, res) => {
      res.json({
        content: 'This is US-only content',
        detectedCountry: req.geo.country
      });
    }
  );

  // Different content per region
  app.get('/api/pricing', (req, res) => {
    const pricing = getPricingForCountry(req.geo.country);
    res.json(pricing);
  });

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

Approach 2: Cloudflare Workers (Edge-Level)

If you use Cloudflare, you get geo headers for free. Cloudflare adds them before the request even reaches your origin server.

// Cloudflare Worker — runs at the edge
export default {
  async fetch(request) {
    // Cloudflare provides these automatically
    const country = request.cf?.country;     // "US"
    const region = request.cf?.region;       // "California"
    const city = request.cf?.city;           // "San Francisco"
    const latitude = request.cf?.latitude;   // "37.7749"
    const longitude = request.cf?.longitude; // "-122.4194"
    const timezone = request.cf?.timezone;   // "America/Los_Angeles"

    const blockedCountries = ['RU', 'CN', 'KP'];

    if (blockedCountries.includes(country)) {
      return new Response(
        JSON.stringify({
          error: 'Service not available in your region',
          code: 'GEO_BLOCKED'
        }),
        {
          status: 403,
          headers: { 'Content-Type': 'application/json' }
        }
      );
    }

    // Pass geo info to origin via headers
    const modifiedRequest = new Request(request);
    modifiedRequest.headers.set('X-Geo-Country', country || 'unknown');
    modifiedRequest.headers.set('X-Geo-Region', region || 'unknown');
    modifiedRequest.headers.set('X-Geo-City', city || 'unknown');

    return fetch(modifiedRequest);
  }
};
Enter fullscreen mode Exit fullscreen mode

This is excellent because the blocking happens at the edge — blocked users don't even reach your server, saving you bandwidth and compute.

Approach 3: AWS CloudFront Geo-Restriction

CloudFront has built-in geo-restriction. No code needed — just configuration.

{
  "DistributionConfig": {
    "Restrictions": {
      "GeoRestriction": {
        "RestrictionType": "whitelist",
        "Quantity": 3,
        "Items": ["US", "CA", "GB"]
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Or using AWS CDK:

import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';

const distribution = new cloudfront.Distribution(this, 'MyDist', {
  defaultBehavior: { origin: myOrigin },
  geoRestriction: cloudfront.GeoRestriction.allowlist(
    'US', 'CA', 'GB', 'DE', 'FR'
  ),
});
Enter fullscreen mode Exit fullscreen mode

CloudFront returns a 403 with a generic error page to blocked users. Simple and effective, but it's all-or-nothing — you can't do per-route restrictions or show different content per region.

Approach 4: Nginx Geo Module

If Nginx is your reverse proxy, you can do geo-blocking at that layer:

# /etc/nginx/conf.d/geoblock.conf

# Load the GeoIP2 module (requires ngx_http_geoip2_module)
geoip2 /usr/share/GeoIP/GeoLite2-Country.mmdb {
    auto_reload 60m;
    $geoip2_data_country_code country iso_code;
}

# Define a map of blocked countries
map $geoip2_data_country_code $blocked_country {
    default    0;
    RU         1;
    CN         1;
    KP         1;
}

server {
    listen 80;
    server_name example.com;

    # Block at the server level
    if ($blocked_country) {
        return 403 '{"error": "Not available in your region"}';
    }

    # Or pass geo info to your backend
    location /api/ {
        proxy_pass http://backend;
        proxy_set_header X-Geo-Country $geoip2_data_country_code;
    }

    # Region-specific routing
    location /content/ {
        if ($geoip2_data_country_code = "US") {
            proxy_pass http://us-backend;
        }
        if ($geoip2_data_country_code = "EU") {
            proxy_pass http://eu-backend;
        }
        proxy_pass http://default-backend;
    }
}
Enter fullscreen mode Exit fullscreen mode

Comparison of Approaches

                     Flexibility   Performance   Ease of Setup   Per-Route Control
                     -----------   -----------   -------------   -----------------
App Middleware       ★★★★★         ★★★           ★★★★            ★★★★★
Cloudflare Worker    ★★★★          ★★★★★         ★★★★★           ★★★★
AWS CloudFront       ★★            ★★★★★         ★★★★★           ★
Nginx GeoIP          ★★★           ★★★★          ★★★             ★★★
Enter fullscreen mode Exit fullscreen mode

Implementing Geofencing on the Frontend

For precise geofencing (trigger actions based on entering/exiting a zone), you need the browser Geolocation API or native mobile APIs.

Setting Up a Geofence with the Browser API

class GeofenceManager {
  constructor() {
    this.fences = [];
    this.watchId = null;
    this.insideFences = new Set();
  }

  // Add a circular geofence
  addFence({ id, latitude, longitude, radiusMeters, onEnter, onExit }) {
    this.fences.push({ id, latitude, longitude, radiusMeters, onEnter, onExit });
  }

  // Haversine formula — distance between two lat/lng points
  getDistanceMeters(lat1, lon1, lat2, lon2) {
    const R = 6371000; // Earth's radius in meters
    const dLat = (lat2 - lat1) * Math.PI / 180;
    const dLon = (lon2 - lon1) * Math.PI / 180;
    const a =
      Math.sin(dLat / 2) * Math.sin(dLat / 2) +
      Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
      Math.sin(dLon / 2) * Math.sin(dLon / 2);
    const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
    return R * c;
  }

  // Start watching position
  start() {
    if (!navigator.geolocation) {
      console.error('Geolocation not supported');
      return;
    }

    this.watchId = navigator.geolocation.watchPosition(
      (position) => this.checkFences(position),
      (error) => console.error('Geolocation error:', error),
      {
        enableHighAccuracy: true,
        maximumAge: 10000,      // Accept 10-second-old positions
        timeout: 15000
      }
    );
  }

  checkFences(position) {
    const { latitude, longitude } = position.coords;

    for (const fence of this.fences) {
      const distance = this.getDistanceMeters(
        latitude, longitude,
        fence.latitude, fence.longitude
      );

      const isInside = distance <= fence.radiusMeters;
      const wasInside = this.insideFences.has(fence.id);

      if (isInside && !wasInside) {
        this.insideFences.add(fence.id);
        fence.onEnter?.({ fence, distance, position });
      } else if (!isInside && wasInside) {
        this.insideFences.delete(fence.id);
        fence.onExit?.({ fence, distance, position });
      }
    }
  }

  stop() {
    if (this.watchId !== null) {
      navigator.geolocation.clearWatch(this.watchId);
      this.watchId = null;
    }
  }
}

// Usage
const manager = new GeofenceManager();

manager.addFence({
  id: 'office',
  latitude: 37.7749,
  longitude: -122.4194,
  radiusMeters: 200,
  onEnter: () => console.log('Welcome to the office!'),
  onExit: () => console.log('You left the office area.')
});

manager.addFence({
  id: 'coffee-shop',
  latitude: 37.7755,
  longitude: -122.4180,
  radiusMeters: 50,
  onEnter: ({ distance }) => {
    showNotification(`Coffee shop nearby! (${Math.round(distance)}m away)`);
  }
});

manager.start();
Enter fullscreen mode Exit fullscreen mode

Handling Permission Denials Gracefully

async function requestLocation() {
  // Check permission status first (Permissions API)
  if (navigator.permissions) {
    const status = await navigator.permissions.query({ name: 'geolocation' });

    if (status.state === 'denied') {
      // Don't even ask — show a helpful message instead
      showUI('location-blocked', {
        message: 'Location access was denied. To use this feature, ' +
                 'enable location in your browser settings.'
      });
      return null;
    }

    // Listen for permission changes
    status.addEventListener('change', () => {
      if (status.state === 'granted') {
        // User just enabled location — retry
        startGeofencing();
      }
    });
  }

  // Now request the position
  return new Promise((resolve, reject) => {
    navigator.geolocation.getCurrentPosition(resolve, reject, {
      enableHighAccuracy: true,
      timeout: 10000
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

Geofencing with Coordinates: The Math

When your geofence isn't a simple circle, you need more sophisticated algorithms.

The Haversine Formula

For "is this point within X km of that point?" — which covers circular geofences:

                    ┌──────────────────┐
                    │   Haversine      │
                    │   Formula        │
                    │                  │
    Point A ──────>│  a = sin²(Δlat/2)│──────> Distance
   (lat, lon)      │    + cos(lat1)   │        in km
                   │    * cos(lat2)   │
    Point B ──────>│    * sin²(Δlon/2)│
   (lat, lon)      │                  │
                    │  d = 2R * atan2( │
                    │    √a, √(1-a))  │
                    └──────────────────┘
Enter fullscreen mode Exit fullscreen mode
import math

def haversine(lat1, lon1, lat2, lon2):
    """Returns distance in kilometers between two points."""
    R = 6371  # Earth's radius in km

    dlat = math.radians(lat2 - lat1)
    dlon = math.radians(lon2 - lon1)

    a = (math.sin(dlat / 2) ** 2 +
         math.cos(math.radians(lat1)) *
         math.cos(math.radians(lat2)) *
         math.sin(dlon / 2) ** 2)

    c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
    return R * c

# Example: Distance between New York and London
dist = haversine(40.7128, -74.0060, 51.5074, -0.1278)
print(f"NYC to London: {dist:.0f} km")  # ~5570 km
Enter fullscreen mode Exit fullscreen mode

Point-in-Polygon (Ray Casting Algorithm)

For irregular geofence shapes (delivery zones, city boundaries, restricted areas), you need point-in-polygon testing:

/**
 * Ray casting algorithm — determines if a point is inside a polygon.
 * Shoots a ray from the point to infinity and counts how many times
 * it crosses polygon edges. Odd = inside, Even = outside.
 *
 * @param {number} lat - Point latitude
 * @param {number} lon - Point longitude
 * @param {Array} polygon - Array of [lat, lon] pairs defining the polygon
 * @returns {boolean}
 */
function isPointInPolygon(lat, lon, polygon) {
  let inside = false;
  const n = polygon.length;

  for (let i = 0, j = n - 1; i < n; j = i++) {
    const [yi, xi] = polygon[i];
    const [yj, xj] = polygon[j];

    const intersect = ((yi > lon) !== (yj > lon)) &&
      (lat < (xj - xi) * (lon - yi) / (yj - yi) + xi);

    if (intersect) inside = !inside;
  }

  return inside;
}

// Define a delivery zone polygon
const deliveryZone = [
  [37.78, -122.42],
  [37.78, -122.40],
  [37.76, -122.40],
  [37.76, -122.42],
];

console.log(isPointInPolygon(37.77, -122.41, deliveryZone)); // true
console.log(isPointInPolygon(37.80, -122.41, deliveryZone)); // false
Enter fullscreen mode Exit fullscreen mode

PostGIS for Database-Level Geo Queries

For serious geofencing at scale, do the math in the database with PostGIS:

-- Enable PostGIS extension
CREATE EXTENSION IF NOT EXISTS postgis;

-- Create a table for geofences
CREATE TABLE geofences (
    id SERIAL PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    fence_type VARCHAR(50) NOT NULL,  -- 'circle' or 'polygon'
    center GEOGRAPHY(POINT, 4326),    -- For circular fences
    radius_meters FLOAT,              -- For circular fences
    boundary GEOGRAPHY(POLYGON, 4326) -- For polygon fences
);

-- Insert a circular geofence (500m radius around Times Square)
INSERT INTO geofences (name, fence_type, center, radius_meters)
VALUES (
    'Times Square Zone',
    'circle',
    ST_GeogFromText('POINT(-73.9857 40.7484)'),
    500
);

-- Insert a polygon geofence (Manhattan rough boundary)
INSERT INTO geofences (name, fence_type, boundary)
VALUES (
    'Manhattan',
    'polygon',
    ST_GeogFromText('POLYGON((-74.0479 40.6829, -73.9718 40.7061,
      -73.9420 40.7748, -73.9299 40.8007, -73.9340 40.8479,
      -74.0096 40.7513, -74.0479 40.6829))')
);

-- Check if a point is inside any geofence
SELECT id, name, fence_type
FROM geofences
WHERE
  (fence_type = 'circle' AND
   ST_DWithin(center, ST_GeogFromText('POINT(-73.9851 40.7580)'), radius_meters))
  OR
  (fence_type = 'polygon' AND
   ST_Within(
     ST_GeogFromText('POINT(-73.9851 40.7580)')::geometry,
     boundary::geometry
   ));

-- Find all users within 1km of a point (for proximity alerts)
SELECT user_id, name,
  ST_Distance(
    location,
    ST_GeogFromText('POINT(-73.9857 40.7484)')
  ) AS distance_meters
FROM users
WHERE ST_DWithin(
  location,
  ST_GeogFromText('POINT(-73.9857 40.7484)'),
  1000  -- 1000 meters
)
ORDER BY distance_meters;
Enter fullscreen mode Exit fullscreen mode

PostGIS uses spatial indexes (R-trees) under the hood, so these queries are fast even with millions of rows.


CDN-Level Geoblocking

The easiest way to implement geo-restriction for most use cases. Let the CDN handle it.

Cloudflare

Cloudflare offers geo-blocking through multiple mechanisms:

  1. IP Access Rules (dashboard): Block entire countries with a few clicks
  2. WAF Custom Rules: More granular — block specific countries for specific paths
  3. Workers (code): Full programmatic control (shown earlier)

Example WAF rule (Cloudflare dashboard expression):

(ip.geoip.country in {"RU" "CN" "KP"}) and (http.request.uri.path contains "/api/")
Enter fullscreen mode Exit fullscreen mode

AWS CloudFront + Lambda@Edge

For more complex logic than CloudFront's built-in geo-restriction:

// Lambda@Edge function (viewer request)
exports.handler = async (event) => {
  const request = event.Records[0].cf.request;
  const headers = request.headers;

  // CloudFront adds this header automatically
  const country = headers['cloudfront-viewer-country']?.[0]?.value;

  const allowedCountries = ['US', 'CA', 'GB', 'DE', 'FR', 'AU'];

  if (country && !allowedCountries.includes(country)) {
    return {
      status: '403',
      statusDescription: 'Forbidden',
      headers: {
        'content-type': [{ value: 'application/json' }],
        'cache-control': [{ value: 'no-store' }]
      },
      body: JSON.stringify({
        error: 'This service is not available in your region.',
        country: country
      })
    };
  }

  // Add geo header for the origin to use
  request.headers['x-viewer-country'] = [{ value: country || 'unknown' }];
  return request;
};
Enter fullscreen mode Exit fullscreen mode

Akamai

Akamai provides EdgeScape data that includes geographic information. You can use it in property configurations or EdgeWorkers:

// Akamai EdgeWorker
import { httpRequest } from 'http-request';

export async function onClientRequest(request) {
  const country = request.getVariable('PMUSER_COUNTRY');

  if (['RU', 'CN', 'KP'].includes(country)) {
    request.respondWith(403, {},
      JSON.stringify({ error: 'Not available in your region' })
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

VPN/Proxy Detection and Bypass Prevention

Geo-restrictions are only as good as your ability to detect circumvention.

The Problem

User in China --> VPN Server in US --> Your Server
                                       |
                                       Sees IP from US
                                       Thinks user is in US
                                       Allows access ❌
Enter fullscreen mode Exit fullscreen mode

Detection Strategies

1. VPN/Proxy IP Databases

Services maintain lists of known VPN, proxy, and Tor exit node IPs:

const maxmind = require('maxmind');

async function checkVPN(ip) {
  // MaxMind GeoIP2 Anonymous IP database (paid)
  const anonLookup = await maxmind.open('/path/to/GeoIP2-Anonymous-IP.mmdb');
  const result = anonLookup.get(ip);

  return {
    isVPN: result?.is_anonymous_vpn || false,
    isProxy: result?.is_public_proxy || false,
    isTorExitNode: result?.is_tor_exit_node || false,
    isHostingProvider: result?.is_hosting_provider || false,
    isResidentialProxy: result?.is_residential_proxy || false
  };
}
Enter fullscreen mode Exit fullscreen mode

2. DNS Leak Detection

Even with a VPN, DNS requests sometimes leak to the user's real ISP:

// Client-side: Force a DNS lookup to your controlled domain
// and check if the resolver IP matches the expected VPN location
async function checkDNSLeak() {
  const uniqueId = crypto.randomUUID();

  // Request a unique subdomain — the DNS query reveals the resolver
  const response = await fetch(`https://${uniqueId}.dns-check.yourservice.com/check`);
  const data = await response.json();

  // Server-side: log which DNS resolver made the query for this uniqueId
  // If the resolver is in a different country than the client IP, flag it
  return data;
}
Enter fullscreen mode Exit fullscreen mode

3. Timezone / Language Mismatch

A user connecting from a "US" IP but with Accept-Language: zh-CN and a timezone offset of +8 is suspicious:

function detectMismatch(req) {
  const geoCountry = req.geo.country; // From IP lookup
  const language = req.headers['accept-language']?.split(',')[0];

  // Client sends timezone via JS
  const clientTimezone = req.body.timezone; // e.g., "Asia/Shanghai"

  const suspiciousSignals = [];

  if (geoCountry === 'US' && language?.startsWith('zh')) {
    suspiciousSignals.push('language_mismatch');
  }

  if (geoCountry === 'US' && clientTimezone?.startsWith('Asia/')) {
    suspiciousSignals.push('timezone_mismatch');
  }

  return {
    suspicious: suspiciousSignals.length > 0,
    signals: suspiciousSignals,
    confidence: suspiciousSignals.length / 2 // rough score
  };
}
Enter fullscreen mode Exit fullscreen mode

4. WebRTC IP Leak (Browser)

WebRTC can reveal a user's real IP even behind a VPN:

// This is used by streaming services to detect VPNs
// Note: Modern browsers are patching this, and most VPN extensions block it
async function getWebRTCIPs() {
  return new Promise((resolve) => {
    const ips = new Set();
    const pc = new RTCPeerConnection({ iceServers: [] });

    pc.createDataChannel('');
    pc.createOffer().then(offer => pc.setLocalDescription(offer));

    pc.onicecandidate = (event) => {
      if (!event.candidate) {
        resolve([...ips]);
        return;
      }
      const parts = event.candidate.candidate.split(' ');
      const ip = parts[4];
      if (ip && !ip.includes(':')) { // Skip IPv6
        ips.add(ip);
      }
    };

    setTimeout(() => resolve([...ips]), 3000);
  });
}
Enter fullscreen mode Exit fullscreen mode

Reality Check on VPN Detection

No VPN detection is perfect. Here's how effective each method is:

Method Detection Rate False Positives Cost
VPN IP databases ~70-85% Low Paid (MaxMind, IPQualityScore)
DNS leak detection ~20-30% Very low Custom infrastructure
Timezone mismatch ~40-50% Medium (travelers) Free
WebRTC leak ~30-40% (declining) Low Free
Combined approach ~85-95% Medium Moderate

Streaming services like Netflix invest heavily in VPN detection and still can't catch everything. For most applications, a VPN IP database plus timezone checks is good enough.


Legal Considerations

Geo-restriction isn't just a technical decision — there are legal implications.

GDPR (EU)

  • If you geoblock EU users, you might not need to comply with GDPR for those users
  • But if even one EU resident accesses your service, you could be subject to it
  • The EU has regulations against unjustified geoblocking within the EU (Regulation 2018/302) — you can't block French users from buying from a German e-commerce site without justification

CCPA (California)

  • Similar to GDPR but for California residents
  • Geoblocking California doesn't exempt you if Californians can still access your service

Data Localization Laws

Some countries require that their citizens' data be stored within the country:

Country Law Requirement
Russia Federal Law No. 242-FZ Personal data of Russian citizens must be stored in Russia
China PIPL + CSL Certain data must remain in China, cross-border transfers need approval
India DPDPA 2023 Government can restrict data transfers to specific countries
Brazil LGPD Less strict, but cross-border transfers need adequate protection
EU GDPR Data can leave EU only to "adequate" countries or with safeguards

Key Takeaway

Geoblocking can help with compliance, but it's not a silver bullet. Consult a lawyer for your specific situation. Seriously.


How the Big Players Handle Geo-Restrictions

Netflix

Netflix operates different content libraries in every country due to licensing agreements.

Their approach:

  • IP geolocation at the edge (likely custom + commercial databases)
  • Aggressive VPN detection (they maintain their own database of VPN/proxy IPs)
  • DNS-based detection (blocks known Smart DNS services)
  • Residential proxy detection (newer — catches the latest evasion tech)
  • Account region locking (your account has a "home" country)

Netflix's system is so thorough that VPN providers actively market "works with Netflix" as a feature — and many fail at it.

Spotify

  • Uses IP geolocation for initial region detection
  • Requires periodic "home location" verification
  • If you travel, you get a grace period before content changes
  • Less aggressive VPN detection than Netflix (music licensing is simpler)

Disney+

  • Similar to Netflix but launched with stricter geoblocking
  • Uses a combination of IP geolocation and payment method country
  • Your billing country must match your viewing country

YouTube

  • Some content is geo-restricted by uploaders
  • YouTube Premium pricing varies by region
  • Uses IP geolocation — relatively easy to bypass compared to Netflix

Common Mistakes and Edge Cases

Mistake 1: Trusting X-Forwarded-For Blindly

// DANGEROUS — any client can set this header
const ip = req.headers['x-forwarded-for'];

// BETTER — only trust it if your proxy/load balancer sets it
// And take the LAST IP added by YOUR infrastructure, not the first
function getTrustedIP(req) {
  const xff = req.headers['x-forwarded-for'];
  if (!xff) return req.socket.remoteAddress;

  const ips = xff.split(',').map(ip => ip.trim());
  // If you have 1 trusted proxy, the real client IP is second from right
  // Adjust based on your proxy chain length
  return ips[ips.length - 1]; // Or ips[0] if you trust all proxies
}
Enter fullscreen mode Exit fullscreen mode

Mistake 2: Failing Closed Without a Fallback

If your GeoIP database fails to load or returns null, what happens? If you block all unknown traffic, a database corruption takes down your entire service.

// Have a fallback strategy
function getGeoDecision(ip) {
  try {
    const result = geoLookup.get(ip);
    if (!result) return { action: 'allow', reason: 'unknown_ip' }; // Fail open
    return { action: isAllowed(result) ? 'allow' : 'block', country: result.country };
  } catch (error) {
    logger.error('GeoIP lookup failed', { ip, error });
    return { action: 'allow', reason: 'lookup_failed' }; // Fail open
  }
}
Enter fullscreen mode Exit fullscreen mode

Mistake 3: Not Updating the GeoIP Database

IP allocations change constantly. If you downloaded the GeoLite2 database a year ago and never updated it, your accuracy has degraded.

# Set up a cron job to update weekly
# (MaxMind requires a license key, even for the free database)
0 3 * * 3 /usr/local/bin/geoipupdate -v
Enter fullscreen mode Exit fullscreen mode

Mistake 4: Caching Geo Decisions Too Aggressively

User with IP 1.2.3.4 --> CDN caches page with "US content"
Different user with IP 5.6.7.8 --> Gets cached US content (wrong!)
Enter fullscreen mode Exit fullscreen mode

Always use Vary: X-Geo-Country or equivalent when caching geo-specific responses.

Mistake 5: Forgetting IPv6

Many GeoIP implementations only handle IPv4. Modern traffic increasingly uses IPv6, and if you can't geolocate it, your blocking has a giant hole.

Mistake 6: Not Handling Travelers

A legitimate US user traveling in Japan shouldn't be permanently locked out. Consider:

  • Grace periods (Spotify gives you 14 days)
  • Account-based region (use signup country, not current IP)
  • Allow users to confirm their home country

Architecture Patterns for Geo-Aware Applications

Pattern 1: Edge + Origin (Recommended for Most)

┌────────────────────────────────────────────────────────┐
│                    CDN / Edge Layer                     │
│                                                        │
│  ┌──────────┐   ┌──────────┐   ┌──────────┐          │
│  │ CF Worker │   │ CF Worker │   │ CF Worker │  ...    │
│  │ (US Edge) │   │ (EU Edge) │   │ (AP Edge) │         │
│  └─────┬─────┘   └─────┬─────┘   └─────┬─────┘        │
│        │ Adds geo      │ Blocks         │ Adds geo     │
│        │ headers       │ if needed      │ headers      │
└────────┼───────────────┼────────────────┼──────────────┘
         │               │                │
         v               v                v
┌────────────────────────────────────────────────────────┐
│                   Origin Server(s)                      │
│                                                        │
│   Reads X-Geo-Country header                           │
│   Serves appropriate content/pricing                   │
│   Handles edge cases (VPN detection, etc.)             │
└────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

The edge layer does coarse geo-blocking (block banned countries) and adds geo headers. The origin server uses those headers for fine-grained logic (pricing, content selection).

Pattern 2: Multi-Region with Geo-Routing

                      DNS (GeoDNS / Route 53)
                              │
                 ┌────────────┼────────────┐
                 │            │            │
                 v            v            v
            ┌────────┐  ┌────────┐  ┌────────┐
            │ US-East │  │ EU-West│  │ AP-SE  │
            │ Region  │  │ Region │  │ Region │
            ├────────┤  ├────────┤  ├────────┤
            │ App    │  │ App    │  │ App    │
            │ DB     │  │ DB     │  │ DB     │
            │ Cache  │  │ Cache  │  │ Cache  │
            └────────┘  └────────┘  └────────┘
Enter fullscreen mode Exit fullscreen mode

For data residency compliance: users in the EU are routed to EU servers with EU-only databases. No data leaves the region.

Pattern 3: Hybrid — Server Decides, Client Refines

1. User opens app
2. Server checks IP --> Country = US --> Serves US content
3. Client requests GPS permission
4. User grants --> Client gets precise coordinates
5. Client sends coordinates to server
6. Server checks point-in-polygon --> User is in delivery zone
7. Server enables local delivery features
Enter fullscreen mode Exit fullscreen mode

This combines IP geolocation (fast, no permission needed) with GPS (precise, permission required) for the best of both worlds.


Quick Decision Guide

Trying to figure out which approach to use? Here's a flowchart:

What do you need?
    │
    ├── Block entire countries from accessing your service?
    │   └── CDN-level geoblocking (Cloudflare, CloudFront)
    │       Easiest, fastest, cheapest
    │
    ├── Show different content/pricing per country?
    │   └── Edge layer (Cloudflare Worker) + origin logic
    │       Geo headers at edge, business logic at origin
    │
    ├── Comply with data residency laws?
    │   └── Multi-region architecture with GeoDNS
    │       Data stays in region, users routed to nearest
    │
    ├── Trigger actions when users enter/exit areas?
    │   └── Browser Geolocation API + Haversine/point-in-polygon
    │       Requires user permission, GPS-level accuracy
    │
    └── Complex geo queries on large datasets?
        └── PostGIS with spatial indexes
            Millions of points/polygons, fast queries
Enter fullscreen mode Exit fullscreen mode

Wrapping Up

Geofencing and geolocking are foundational capabilities for any application that serves users globally. The key takeaways:

  1. IP geolocation is good enough for country-level restrictions — use MaxMind GeoLite2 or your CDN's built-in geo headers.
  2. CDN-level blocking is the easiest first step — Cloudflare and CloudFront can block countries with zero code.
  3. For precise geofencing, you need GPS — which means user permission and client-side code.
  4. VPN detection is an arms race — use a commercial IP reputation database if it matters to your business.
  5. Don't forget the legal side — geoblocking has compliance implications in both directions.
  6. Always have a fallback — GeoIP databases can fail, IPs can be unresolvable, and travelers exist.

Start simple (CDN-level blocking or a basic middleware), and add complexity only when your requirements demand it.


If you found this guide helpful and want to stay updated on more engineering deep-dives, connect with me on LinkedIn. I regularly share insights on backend development, system design, and building for scale. Let's connect!

Top comments (0)