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
- Extract the client's IP from the request
- Look up their country via IP geolocation
- 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
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 };
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'));
Allow only specific countries
// Only allow US and EU countries
app.use(geoBlock({
allowedCountries: ['US', 'GB', 'DE', 'FR', 'NL', 'SE', 'CA', 'AU'],
}));
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'
});
}
}));
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 });
});
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' });
}
}));
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' });
}
}));
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"
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"
}
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);
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)