Home → Block IP addresses in Node.js and Express: net.BlockList, ::ffff: addresses and X-Forwarded-For spoofing

Block IP addresses in Node.js and Express: net.BlockList, ::ffff: addresses and X-Forwarded-For spoofing

By · Node.js & JavaScript developer
Published March 11, 2023 · Updated October 1, 2026

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:

  1. The address has a ::ffff: prefix. When listen() 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.
  2. X-Forwarded-For is 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_TYPE

The 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 proxyThrough nginxThrough nginx, with X-Forwarded-For: 8.8.8.8Direct to the app, with X-Forwarded-For: 8.8.8.8
not set200, req.ip is nginx (::ffff:172.30.0.2)200403
true-200, req.ip = 8.8.8.8-
'uniquelocal'403200, req.ip = 8.8.8.8-
1 (one hop)403403200, req.ip = 8.8.8.8
'172.30.0.2' (nginx's address)403403403
  • Not set: every request looks like it comes from nginx, so you either block nobody or block everybody.
  • true takes 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.
  • 1 is 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-For is 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

About Code with Node.js

This is a personal blog and reference point of a Node.js developer.

I write and explain how different Node and JavaScript aspects work, as well as research popular and cool packages, and of course fail time to time.