Block IP addresses in Node.js and Express: net.BlockList, ::ffff: addresses and X-Forwarded-For spoofing
Blocking an abusive client by IP address takes a few lines of code, but it's easy to write a version that looks right and blocks nobody. This post starts with the snippet from the original version of this article, shows exactly how it fails, and then builds a version that holds up, both for a plain node:http server and for Express behind a reverse proxy.
Every scenario ran on a Docker network with fixed addresses: the client to block was 172.30.0.10, a normal client was 172.30.0.11, nginx 1.29 was 172.30.0.2, and the app ran on node:24-slim (Node 24.21) and node:26-slim (Node 26.10), with identical results. Requests were sent with curl from containers given those addresses.
The snippet that doesn't work
import http from 'node:http';
http.createServer((req, res) => {
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
if (ip == '172.30.0.10') {
res.writeHead(403);
return res.end('Error 403: Your IP is blocked\n');
}
res.end(`It works! (ip seen: ${ip})\n`);
}).listen(3130);blocked client It works! (ip seen: ::ffff:172.30.0.10) [200]
blocked client + X-Forwarded-For: 8.8.8.8 It works! (ip seen: 8.8.8.8) [200]
other client It works! (ip seen: ::ffff:172.30.0.11) [200]Two separate bugs:
- The address has a
::ffff:prefix. Whenlisten()is called without a host, Node listens on the IPv6 address::, which on most systems also accepts IPv4. IPv4 clients then show up as IPv4-mapped IPv6 addresses,::ffff:172.30.0.10, so a string comparison with'172.30.0.10'never matches. The blocked client got straight in, without even trying. X-Forwarded-Foris just a request header. Any client can send one, with any value. Reading it first means every visitor chooses their own IP address.
net.BlockList: built-in matching for addresses, ranges and subnets
Node has a built-in class for this: net.BlockList, available since Node 15. It parses addresses properly, so it handles ranges, CIDR subnets and IPv6, and it understands IPv4-mapped addresses:
import net from 'node:net';
const blocked = new net.BlockList();
blocked.addAddress('172.30.0.10'); // one address
blocked.addSubnet('198.51.100.0', 24); // a whole range
blocked.addSubnet('2001:db8::', 32, 'ipv6'); // IPv6 needs the type
export function isBlocked(ip) {
const family = net.isIP(ip ?? '');
if (family === 0) return true; // no usable address: refuse rather than allow
// "::ffff:172.30.0.10" is IPv6 notation, and matches the IPv4 rule only when checked as ipv6
return blocked.check(ip, family === 6 ? 'ipv6' : 'ipv4');
}How check() responded to different inputs (the same on Node 24 and 26):
check("203.0.113.7") true ← addAddress
check("198.51.100.200") true ← inside addSubnet('198.51.100.0', 24)
check("192.0.2.15") true ← inside addRange('192.0.2.10', '192.0.2.20')
check("2001:db8::1", "ipv6") true
check("2001:db8::1") false ← type defaults to 'ipv4'
check("::ffff:203.0.113.7", "ipv6") true ← IPv4-mapped, matches the IPv4 rule
check("::ffff:203.0.113.7") false ← same address, wrong type
check("not-an-ip") false
check() ERR_INVALID_ARG_TYPEThe trap is the second argument: check() assumes 'ipv4', and with the wrong type it silently returns false instead of throwing. That's why isBlocked() uses net.isIP() to pass the right type. Checked as 'ipv6', ::ffff:203.0.113.7 matches the IPv4 rule, so you don't need to strip the prefix yourself. isBlocked() also refuses a request that has no valid address, rather than letting it through.
New in Node 24.21 and 26.8: addCIDR('198.51.100.0/24'), addCIDRs([...]) and addAddresses([...]) take CIDR strings and lists directly, and net.BlockList.PRIVATE_RANGES lists the private, loopback and link-local ranges. Node 22 doesn't have them yet, so use addSubnet() if you need to support it.
> net.BlockList.PRIVATE_RANGES
[
'10.0.0.0/8',
'172.16.0.0/12',
'192.168.0.0/16',
'127.0.0.0/8',
'::1/128',
'169.254.0.0/16',
'fe80::/10',
'fc00::/7'
]A plain node:http server
When clients connect directly to Node, the socket address is the only one you can trust:
import http from 'node:http';
import { isBlocked } from './blocklist.mjs';
http.createServer((req, res) => {
const ip = req.socket.remoteAddress;
if (isBlocked(ip)) {
res.writeHead(403, { 'Content-Type': 'text/plain' });
return res.end('Forbidden\n');
}
res.end(`Hello, ${ip}\n`);
}).listen(3130);172.30.0.10 Forbidden [403]
172.30.0.10, X-Forwarded-For: 8.8.8.8 Forbidden [403]
172.30.0.11 Hello, ::ffff:172.30.0.11 [200]Both the plain and the spoofed request from the blocked client are refused, and the other client still gets through.
Express behind a reverse proxy
Behind nginx, a load balancer or a CDN, every request comes from the proxy, so the socket address is the proxy's. The real client address is in X-Forwarded-For, which nginx fills in with $proxy_add_x_forwarded_for. That variable appends the client's address to whatever the client sent:
server {
listen 80;
location / {
proxy_pass http://172.30.0.3:3130;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}So the header can contain values the client made up, followed by the address nginx actually saw. Express's trust proxy setting decides how much of the header req.ip trusts. It reads the entries from right to left and stops at the first address that isn't trusted. Here's what each setting did with the blocked client, through nginx and directly:
trust proxy | Through nginx | Through nginx, with X-Forwarded-For: 8.8.8.8 | Direct to the app, with X-Forwarded-For: 8.8.8.8 |
|---|---|---|---|
| not set | 200, req.ip is nginx (::ffff:172.30.0.2) | 200 | 403 |
true | - | 200, req.ip = 8.8.8.8 | - |
'uniquelocal' | 403 | 200, req.ip = 8.8.8.8 | - |
1 (one hop) | 403 | 403 | 200, req.ip = 8.8.8.8 |
'172.30.0.2' (nginx's address) | 403 | 403 | 403 |
- Not set: every request looks like it comes from nginx, so you either block nobody or block everybody.
truetakes the left-most entry, which is whatever the client wrote. Only use it if your outermost proxy replaces the header instead of appending to it.'uniquelocal'trusts every private address. In this test the client was on a private network too, so Express trusted the client and then believed its forged entry. With clients on the public internet it would work, but it fails exactly in office networks, VPNs and internal tools.1is correct as long as every request really goes through nginx. If the app's port is also reachable directly, the client becomes the "one hop" and its forged header is trusted.- The proxy's own address was correct in every case:
X-Forwarded-Foris only used when the request actually came from nginx.
So name your proxies explicitly:
import express from 'express';
import { isBlocked } from './blocklist.mjs';
const app = express();
// Use X-Forwarded-For only when the request came from our nginx
app.set('trust proxy', '172.30.0.2');
app.use((req, res, next) => {
if (isBlocked(req.ip)) return res.status(403).type('text').send('Forbidden\n');
next();
});
app.get('/', (req, res) => res.type('text').send(`Hello, ${req.ip}\n`));
app.listen(3130);172.30.0.10 via nginx Forbidden [403]
172.30.0.10 via nginx, X-Forwarded-For: 8.8.8.8 Forbidden [403]
172.30.0.10 direct, X-Forwarded-For: 8.8.8.8 Forbidden [403]
172.30.0.11 via nginx Hello, 172.30.0.11 [200]trust proxy also accepts a comma-separated list, an array, CIDR subnets and a function, so a load balancer pool or a CDN's published IP ranges work the same way. Without Express, the same logic is available as the proxy-addr package, which is what Express uses internally.
On Cloudflare, the client address is in the CF-Connecting-IP header, but the same rule applies: trust it only when the request came from Cloudflare. Detect a user's country from their IP address in Node.js shows a working example of this.
Block at the edge when you can
Blocking in Node is useful for application-level rules: banning a user's address after repeated failed logins, or restricting an admin route to an office network. For large lists, or traffic you don't want to reach Node at all, block earlier instead: nginx's deny directive, your cloud firewall, or your CDN's IP access rules. Keep in mind that IP blocking is a blunt tool. Many users can share one address (mobile carriers, offices, universities), and one attacker can switch addresses in seconds.
Sources & further reading
- Node.js docs: net.BlockList (
check(),addSubnet(),addCIDR(),PRIVATE_RANGES) - Node.js docs: server.listen() (listening on
::when no host is given) - Node.js docs: net.isIP()
- Express: behind proxies (
trust proxyvalues and the spoofing warning) - proxy-addr (the trust logic Express uses)
- nginx: $proxy_add_x_forwarded_for and the access module (allow/deny)
- MDN: X-Forwarded-For (security and privacy concerns)
- RFC 4291: IPv4-mapped IPv6 addresses