DEV Community

Ozor
Ozor

Posted on

How to Geo-Block Countries in Express.js (Free IP Geolocation API)

Ever need to restrict your app to specific countries? Whether it's compliance (GDPR, sanctions), licensing, or fraud prevention — geo-blocking is a common requirement that most tutorials overcomplicate.

Here's a clean, production-ready approach using Express.js and a free IP geolocation API.

The Approach

  1. Extract the client's IP from the request
  2. Look up their country via IP geolocation
  3. Allow, block, or redirect based on rules

No database downloads. No MaxMind account. No stale GeoIP files.

Setup

mkdir geo-blocking && cd geo-blocking
npm init -y
npm install express
Enter fullscreen mode Exit fullscreen mode

Get a free API key (200 credits, no credit card): Frostbyte API Gateway

The Middleware

// geo-block.js
const API_KEY = process.env.FROSTBYTE_KEY;
const GEO_API = 'https://api.frostbyte.wiki';

// Cache lookups to avoid redundant API calls
const geoCache = new Map();
const CACHE_TTL = 60 * 60 * 1000; // 1 hour

async function getCountry(ip) {
  const cached = geoCache.get(ip);
  if (cached && Date.now() - cached.ts < CACHE_TTL) {
    return cached.country;
  }

  const res = await fetch(`${GEO_API}/api/geo/${ip}`, {
    headers: { 'x-api-key': API_KEY }
  });

  if (!res.ok) return null;

  const data = await res.json();
  const country = data.country_code;

  geoCache.set(ip, { country, ts: Date.now() });
  return country;
}

function geoBlock(options = {}) {
  const {
    blockedCountries = [],
    allowedCountries = [],
    onBlocked = (req, res) => res.status(403).json({
      error: 'Access denied',
      reason: 'This service is not available in your region'
    })
  } = options;

  return async (req, res, next) => {
    const ip = req.headers['x-forwarded-for']?.split(',')[0]?.trim()
      || req.socket.remoteAddress;

    // Skip localhost
    if (ip === '127.0.0.1' || ip === '::1') return next();

    const country = await getCountry(ip);

    if (!country) return next(); // fail open if API is down

    // If allowlist is set, only those countries pass
    if (allowedCountries.length > 0) {
      if (!allowedCountries.includes(country)) {
        return onBlocked(req, res);
      }
      return next();
    }

    // If blocklist is set, those countries are denied
    if (blockedCountries.includes(country)) {
      return onBlocked(req, res);
    }

    // Attach country to request for downstream use
    req.geoCountry = country;
    next();
  };
}

module.exports = { geoBlock, getCountry };
Enter fullscreen mode Exit fullscreen mode

Using It

Block specific countries

const express = require('express');
const { geoBlock } = require('./geo-block');

const app = express();

// Block access from specific countries
app.use(geoBlock({
  blockedCountries: ['CN', 'RU', 'KP'],
}));

app.get('/', (req, res) => {
  res.json({ message: 'Welcome!', country: req.geoCountry });
});

app.listen(3000, () => console.log('Server running on :3000'));
Enter fullscreen mode Exit fullscreen mode

Allow only specific countries

// Only allow US and EU countries
app.use(geoBlock({
  allowedCountries: ['US', 'GB', 'DE', 'FR', 'NL', 'SE', 'CA', 'AU'],
}));
Enter fullscreen mode Exit fullscreen mode

Custom blocked response

app.use(geoBlock({
  blockedCountries: ['CN'],
  onBlocked: (req, res) => {
    res.status(451).json({
      error: 'Unavailable For Legal Reasons',
      message: 'This content is not available in your region.',
      support: 'contact@example.com'
    });
  }
}));
Enter fullscreen mode Exit fullscreen mode

Per-route blocking

const euOnly = geoBlock({
  allowedCountries: ['DE', 'FR', 'NL', 'IT', 'ES', 'SE', 'PL', 'BE', 'AT'],
});

app.get('/api/public', (req, res) => {
  res.json({ status: 'open to everyone' });
});

app.get('/api/eu-data', euOnly, (req, res) => {
  res.json({ status: 'EU-only endpoint', country: req.geoCountry });
});
Enter fullscreen mode Exit fullscreen mode

Redirect Instead of Block

Sometimes you want to redirect users to a localized version:

const REGIONAL_SITES = {
  DE: 'https://de.example.com',
  FR: 'https://fr.example.com',
  JP: 'https://jp.example.com',
};

app.use(geoBlock({
  blockedCountries: Object.keys(REGIONAL_SITES),
  onBlocked: (req, res) => {
    const ip = req.headers['x-forwarded-for']?.split(',')[0]?.trim()
      || req.socket.remoteAddress;

    // Country already cached from the check
    const cached = geoCache.get(ip);
    const site = REGIONAL_SITES[cached?.country];

    if (site) {
      return res.redirect(302, site + req.originalUrl);
    }
    res.status(403).json({ error: 'Region not supported' });
  }
}));
Enter fullscreen mode Exit fullscreen mode

Logging Blocked Requests

Add visibility into what's being blocked:

app.use(geoBlock({
  blockedCountries: ['CN', 'RU'],
  onBlocked: (req, res) => {
    const ip = req.headers['x-forwarded-for']?.split(',')[0]?.trim()
      || req.socket.remoteAddress;

    console.log(`[GEO-BLOCK] ${new Date().toISOString()} | ${ip} | ${req.geoCountry || 'unknown'} | ${req.method} ${req.path}`);

    res.status(403).json({ error: 'Access denied in your region' });
  }
}));
Enter fullscreen mode Exit fullscreen mode

Testing

Test with curl by spoofing the forwarded header:

# Test with a US IP
curl -H "X-Forwarded-For: 8.8.8.8" http://localhost:3000/

# Test with a CN IP (will be blocked)
curl -H "X-Forwarded-For: 36.110.233.53" http://localhost:3000/

# Test the geolocation lookup directly
curl "https://api.frostbyte.wiki/api/geo/8.8.8.8" \
  -H "x-api-key: YOUR_KEY"
Enter fullscreen mode Exit fullscreen mode

Response from the geo API:

{
  "ip": "8.8.8.8",
  "country": "United States",
  "country_code": "US",
  "region": "Virginia",
  "city": "Ashburn",
  "latitude": 39.0438,
  "longitude": -77.4874,
  "timezone": "America/New_York",
  "isp": "Google LLC"
}
Enter fullscreen mode Exit fullscreen mode

Production Considerations

Fail open vs. fail closed: The middleware above fails open (allows access if the API is down). For high-security scenarios, change if (!country) return next() to block by default.

Caching: The in-memory cache prevents duplicate lookups. For multi-instance deployments, swap Map() for Redis.

IPv6: The IP geolocation API handles both IPv4 and IPv6 addresses.

Proxies and CDNs: Always check X-Forwarded-For first. If you're behind Cloudflare, use CF-Connecting-IP instead.

Full Working Example

const express = require('express');
const { geoBlock, getCountry } = require('./geo-block');

const app = express();

// Global: block sanctioned countries
app.use(geoBlock({ blockedCountries: ['KP', 'IR', 'SY'] }));

// Public route
app.get('/', (req, res) => {
  res.json({ hello: 'world', yourCountry: req.geoCountry });
});

// EU-only route
app.get('/eu', geoBlock({
  allowedCountries: ['DE', 'FR', 'NL', 'IT', 'ES', 'SE', 'PL', 'BE', 'AT', 'IE', 'PT', 'FI', 'DK', 'CZ', 'RO', 'HU', 'SK', 'BG', 'HR', 'SI', 'LT', 'LV', 'EE', 'CY', 'LU', 'MT'],
}), (req, res) => {
  res.json({ message: 'EU-only content', country: req.geoCountry });
});

// Manual country check
app.get('/check/:ip', async (req, res) => {
  const country = await getCountry(req.params.ip);
  res.json({ ip: req.params.ip, country });
});

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

Get a free API key (200 credits, no credit card): Frostbyte API Gateway

The IP geolocation API returns country, region, city, ISP, and timezone — all from a single endpoint. Works with both IPv4 and IPv6.

If you're building something with geo-restrictions, I'd love to hear about your use case in the comments.

Top comments (0)