Server Timeouts, Limits & Hardening
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences
of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; versions, defaults and stability levels were
checked against the official documentation on 2026-10-04, and every example below was run on all three lines unless the text says otherwise.
The node:http module is Stability 2 (Stable), and every option and property on this page belongs to that stable surface.
A Node.js HTTP server accepts bytes from strangers. Before your request handler runs, the runtime has already decided how long it is willing
to wait for those bytes, how many of them it will buffer, and which malformed messages it will refuse. Those decisions are a handful of
properties on http.Server, a few parser rules that you must not weaken, and, in production, a reverse proxy that changes who the
"client" is. This page is the checklist: timeouts (headersTimeout, requestTimeout, timeout, keepAliveTimeout), size limits (header size
and count, body size), connection limits, the slowloris attack, HTTP request smuggling (CWE-444), and running behind a reverse proxy
(trust proxy, X-Forwarded-*). The official threat model behind it is the
Security Best Practices guide; the page
Security Best Practices in this section follows it end to end, and
Don’t Block the Event Loop covers the other denial-of-service family (CPU-bound handlers).
The server itself is introduced in HTTP Server Fundamentals; TLS and HTTP/2 are in
HTTPS, TLS & HTTP/2. The running scenario is the Bookshelf application that grows across this
section, described in Introduction & Architecture.
The threat model in one table
The official guide classifies the first two rows under Denial of Service of HTTP server (CWE-400) and the third under HTTP Request Smuggling (CWE-444). The server does not parse the meaning of a request body, so what is in the body is your application’s responsibility; how the bytes arrive is the runtime’s, and it is configurable.
| Attack | What the attacker does | What stops it |
|---|---|---|
Slowloris |
Opens many connections and sends the header block one fragment at a time, never finishing, so each connection ties up resources. |
|
Slow body ("slow POST") |
Sends complete headers, then trickles the body one byte at a time. |
|
Oversized request |
Sends huge headers, thousands of headers, or an enormous body. |
|
Idle connection hoarding |
Opens keep-alive connections and never uses them. |
|
Request smuggling |
Sends a request that the proxy and Node.js frame differently (where does the body end?) to slip a second request past the proxy. |
The strict default parser (never |
Spoofed client address |
Sends its own |
Trusting forwarding headers only from proxies you operate ( |
The knobs and their defaults
All of these are properties of http.Server (and most are also options of http.createServer()). The right-hand column was observed by
printing the properties of a fresh server on Node.js 26.10.0, 24.21.0 and 22.23.3; the three lines gave identical values.
| Property or option | Default | What it controls, and history |
|---|---|---|
|
|
Time allowed to receive the complete header block. On expiry the server answers |
|
|
Time allowed to receive the entire request, headers and body. On expiry the server answers |
|
|
Socket inactivity timeout. Emits a |
|
|
How long an idle keep-alive socket is kept after the last response. |
|
|
Extra time added to |
|
|
How often the server scans for connections whose |
|
|
Maximum number of header fields; |
|
|
Maximum size of the request header block in bytes; the global default is set with the |
|
|
Requests served on one keep-alive socket before the server sets |
|
none |
Above this number the server drops new connections and emits a |
The properties are read when a connection is accepted, not on every request: the docs for server.timeout and server.keepAliveTimeout both say that
changing them affects only new connections. Set them before calling listen().
The documentation of Node.js 26.10.0 disagrees with itself about keepAliveTimeout: the server.keepAliveTimeout entry and the observed value are 5 s, while
the option list of http.createServer() says 65000. The development version of the docs carries a pending note that changes the default from 5 to 65 seconds, so a
future release may ship a different default. Do not depend on either value: set keepAliveTimeout explicitly (see
Running behind a reverse proxy).
Timeouts
headersTimeout and requestTimeout: the slowloris defence
Slowloris is the oldest trick in the book: open a connection, send GET / HTTP/1.1 and a header or two, then send one more header every few
seconds, forever. No single request is large; the attack is about holding sockets. The official guide describes it as a denial of service that
depends "not on the request’s contents but on the timing and pattern of the requests", and its mitigations are a reverse proxy, correctly configured
server timeouts, and limits on the number of open sockets.
The example below shortens the timeouts so the effect is visible. The client sends a partial header block and then goes quiet; the server
closes the connection with 408 after roughly headersTimeout. Because expiry is only checked every connectionsCheckingInterval, the
close lands up to one interval after the deadline, which is why a production server with the default 30 s interval can hold an attacker for
headersTimeout plus up to 30 s.
import { createServer } from 'node:http';
import { connect } from 'node:net';
const server = createServer({
headersTimeout: 1_000, // whole header block must arrive in 1 s
requestTimeout: 3_000, // whole request (headers + body) in 3 s
keepAliveTimeout: 5_000, // idle keep-alive connections closed after 5 s
connectionsCheckingInterval: 250, // how often the two timeouts above are checked
maxHeaderSize: 8 * 1024, // 8 KiB of request headers at most
}, (req, res) => {
res.end('ok\n');
});
server.maxHeadersCount = 50; // at most 50 header fields
server.maxRequestsPerSocket = 100; // recycle a keep-alive socket after 100 requests
server.listen(0, '127.0.0.1', () => {
const { port } = server.address();
// A "slowloris" client: send a partial header block, then stall.
const socket = connect(port, '127.0.0.1');
socket.setEncoding('latin1');
const started = Date.now();
let response = '';
socket.on('data', (chunk) => { response += chunk; });
socket.on('close', () => {
console.log(`closed after ~${Math.round((Date.now() - started) / 100) / 10} s`);
console.log(response.split('\r\n')[0]);
server.close();
});
socket.write('GET / HTTP/1.1\r\nHost: localhost\r\nX-Slow: 1\r\n'); // never sends the blank line
});
closed after ~1 s
HTTP/1.1 408 Request Timeout
Source: server.headersTimeout
headersTimeout bounds only the header block. A client that completes its headers and then trickles the body is caught by requestTimeout, which
covers the whole request. It is checked while your handler is already running, so the handler learns about it as an aborted request.
import { createServer } from 'node:http';
import { connect } from 'node:net';
import { setTimeout as sleep } from 'node:timers/promises';
const server = createServer({
headersTimeout: 1_000,
requestTimeout: 1_500, // a 1-byte-per-second upload cannot finish in time
connectionsCheckingInterval: 250,
}, async (req, res) => {
try {
for await (const chunk of req) { /* consume */ }
res.end('done\n');
} catch (err) {
console.log('handler saw:', err.code ?? err.message);
}
});
server.listen(0, '127.0.0.1', async () => {
const { port } = server.address();
const socket = connect(port, '127.0.0.1');
socket.setEncoding('latin1');
let response = '';
socket.on('data', (c) => { response += c; });
socket.on('error', () => {});
socket.on('close', () => {
console.log('client got:', response.split('\r\n')[0] || '(connection closed, no response)');
server.close();
});
// Headers are complete (so headersTimeout is satisfied) but the body trickles.
socket.write('POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 100\r\n\r\n');
for (let i = 0; i < 10 && !socket.destroyed; i++) {
socket.write('a');
await sleep(500);
}
});
handler saw: ECONNRESET
client got: HTTP/1.1 408 Request Timeout
Source: server.requestTimeout
Choose the two numbers from the slowest legitimate client, not from the fastest. A mobile user uploading a photo over a poor link needs far
longer than 5 s for the body, so a per-route override (or a separate upload server) is better than raising the global value. The two properties interact:
the default headersTimeout is the smaller of requestTimeout and 60 s, and setting requestTimeout below headersTimeout makes the
shorter value the effective one. Both must be non-zero to protect a server that has no reverse proxy in front; the docs say so explicitly, and
setting either to 0 disables the check.
timeout, the 'timeout' event and keepAliveTimeout
server.timeout is a different mechanism: a plain socket inactivity timer, off by default since v13.0.0. It does not distinguish a stalled upload from a long
server computation, so most applications leave it at 0 and rely on headersTimeout, requestTimeout and, for outbound work, AbortSignal.timeout()
(see Async Context & Cancellation). If you do set it, remember that a 'timeout' listener on the server makes
you responsible for closing the socket.
keepAliveTimeout governs the quiet period between requests on a persistent connection. It is a resource limit (idle sockets cost file
descriptors) and, behind a proxy, a correctness setting, covered in the proxy section below. server.maxRequestsPerSocket recycles busy connections after a
number of requests, which spreads load across a pool of upstream servers instead of letting one connection live for days.
Graceful shutdown and idle connections
server.close() stops accepting connections and, since Node.js 19.0.0, also closes the idle keep-alive connections, so a server with
only idle clients exits promptly (the final example on this page shows it). server.closeIdleConnections() and server.closeAllConnections()
exist for older versions and for forcing the issue; the docs recommend calling them after server.close(). closeAllConnections() is forceful and does not touch upgraded
sockets such as WebSockets. The shutdown choreography as a whole belongs to
Process Signals & Exit Codes and Production Deployment.
Size limits
Header size and header count
The header block is limited to maxHeaderSize bytes (16 KiB by default) and to maxHeadersCount fields. When a request exceeds the byte limit
the parser reports HPE_HEADER_OVERFLOW and the default 'clientError' handler answers 431 Request Header Fields Too Large. The header-count
limit produced the same 431 in the test below. Large cookies and bearer tokens are the usual legitimate causes of header overflows, so
raise maxHeaderSize only for a measured reason: every byte allowed is a byte an attacker can make you buffer, per connection.
import { createServer } from 'node:http';
import { connect } from 'node:net';
const server = createServer({ maxHeaderSize: 2048 }, (req, res) => res.end('ok\n'));
server.maxHeadersCount = 10;
function raw(port, text) {
return new Promise((resolve) => {
const socket = connect(port, '127.0.0.1');
let data = '';
socket.setEncoding('latin1');
socket.on('data', (c) => { data += c; });
socket.on('close', () => resolve(data.split('\r\n')[0]));
socket.write(text);
});
}
server.listen(0, '127.0.0.1', async () => {
const { port } = server.address();
const head = 'GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n';
console.log('normal :', await raw(port, `${head}\r\n`));
console.log('big header :', await raw(port, `${head}X-Big: ${'a'.repeat(4096)}\r\n\r\n`));
const many = Array.from({ length: 30 }, (_, i) => `X-H${i}: 1\r\n`).join('');
console.log('many headers:', await raw(port, `${head}${many}\r\n`));
server.close();
});
normal : HTTP/1.1 200 OK
big header : HTTP/1.1 431 Request Header Fields Too Large
many headers: HTTP/1.1 431 Request Header Fields Too Large
Source: http.createServer()
Body size
Node.js does not limit request bodies: the IncomingMessage is a stream, and it is up to the handler to stop reading. A limit has three parts.
Reject early when Content-Length already exceeds the budget (cheap, but only a hint, since the header can lie and chunked requests have none),
count the bytes as they arrive (the authoritative check), and answer 413 Content Too Large while closing the connection so the client stops sending.
The loop below does that with for await, which also gives backpressure for free (see Streams).
import { createServer, request } from 'node:http';
const MAX_BODY = 1024; // bytes
class PayloadTooLarge extends Error {
statusCode = 413;
}
async function readJson(req, limit = MAX_BODY) {
// Cheap early rejection: believe Content-Length only to say "no", never to say "yes".
const declared = Number(req.headers['content-length']);
if (declared > limit) throw new PayloadTooLarge('declared body too large');
const chunks = [];
let received = 0;
for await (const chunk of req) { // the stream applies backpressure for us
received += chunk.length;
if (received > limit) throw new PayloadTooLarge('body too large'); // also covers chunked bodies
chunks.push(chunk);
}
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
}
const server = createServer(async (req, res) => {
try {
const body = await readJson(req);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ keys: Object.keys(body) }));
} catch (err) {
const status = err.statusCode ?? (err instanceof SyntaxError ? 400 : 500);
res.writeHead(status, { 'Content-Type': 'application/json', Connection: 'close' });
res.end(JSON.stringify({ error: err.message }));
}
});
server.listen(0, '127.0.0.1', async () => {
const { port } = server.address();
const send = (body, headers = {}) => new Promise((resolve) => {
const req = request({ port, method: 'POST', headers }, (res) => {
let data = '';
res.on('data', (c) => { data += c; });
res.on('end', () => resolve(`${res.statusCode} ${data}`));
});
req.on('error', (e) => resolve(`client error ${e.code}`));
req.end(body);
});
console.log('small :', await send(JSON.stringify({ title: 'Dune' })));
console.log('large :', await send(JSON.stringify({ blob: 'x'.repeat(5000) })));
// Transfer-Encoding: chunked (no Content-Length) is only caught by the counting loop.
const chunked = new Promise((resolve) => {
const req = request({ port, method: 'POST' }, (res) => {
let data = '';
res.on('data', (c) => { data += c; });
res.on('end', () => resolve(`${res.statusCode} ${data}`));
});
req.on('error', (e) => resolve(`client error ${e.code}`));
req.write('{"blob":"');
req.write('x'.repeat(5000));
req.end('"}');
});
console.log('chunked:', await chunked);
server.close();
});
small : 200 {"keys":["title"]}
large : 413 {"error":"declared body too large"}
chunked: 413 {"error":"body too large"}
Source: Anatomy of an HTTP Transaction
The same discipline is built into the frameworks, but the defaults differ, which is a common source of surprises (checked with Express 5.2.1 and Fastify 5.12.5):
| Framework | Body limit | Server timeouts |
|---|---|---|
Express 5 |
|
|
Fastify 5 |
|
The |
import express from 'express';
import Fastify from 'fastify';
// Express 5: express.json() enforces a body limit (default '100kb') and the error maps to 413.
const app = express();
app.use(express.json({ limit: '10kb' }));
app.post('/', (req, res) => res.json({ ok: true }));
const expressServer = app.listen(0, '127.0.0.1');
// app.listen() returns the plain node:http server, so every knob from this page applies to it.
expressServer.headersTimeout = 15_000;
expressServer.requestTimeout = 60_000;
// Fastify 5: bodyLimit defaults to 1 MiB; requestTimeout defaults to 0 (no limit!), so set it yourself.
const fastify = Fastify({ bodyLimit: 10 * 1024, requestTimeout: 60_000, keepAliveTimeout: 72_000 });
fastify.post('/', async () => ({ ok: true }));
await fastify.listen({ port: 0, host: '127.0.0.1' });
const big = JSON.stringify({ blob: 'x'.repeat(20_000) });
const post = (server) => fetch(`http://127.0.0.1:${server.address().port}/`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: big,
}).then((r) => r.status);
console.log('express ->', await post(expressServer));
console.log('fastify ->', await post(fastify.server));
console.log('fastify defaults:', Fastify().server.requestTimeout, Fastify().server.keepAliveTimeout);
expressServer.close();
await fastify.close();
PayloadTooLargeError to stderr; identical on Node.js 26, 24 and 22)express -> 413
fastify -> 413
fastify defaults: 0 72000
Multipart uploads need their own limits (file size, file count, field size) in whichever parser you use, and streaming the upload to disk or object storage instead of buffering it is the real fix for large files; see File Upload & Streaming for the NestJS view of the same problem. A limit at the proxy (for example a maximum body size) is a useful first line of defence but never a substitute for the one in the application.
Connection limits
server.maxConnections is inherited from net.Server: past the limit the server destroys new connections and emits 'drop' with the peer’s address, which
is a cheap place to count rejected connections. It is a blunt instrument (a flood of idle connections can use up the budget and lock legitimate users out),
so it complements the timeouts rather than replacing them, and per-client limits belong in the proxy or firewall.
import { createServer } from 'node:http';
import { connect } from 'node:net';
import { once } from 'node:events';
import { setTimeout as sleep } from 'node:timers/promises';
const server = createServer((req, res) => res.end('ok\n'));
server.maxConnections = 2; // beyond this the OS accepts, Node drops
server.on('drop', ({ remoteAddress }) => console.log('dropped a connection from', remoteAddress));
server.listen(0, '127.0.0.1', async () => {
const { port } = server.address();
const sockets = [];
for (let i = 0; i < 3; i++) { // three idle clients, one more than allowed
const socket = connect(port, '127.0.0.1');
socket.on('error', () => {});
sockets.push(socket);
await once(socket, 'connect');
await sleep(50);
}
console.log('open on server:', await new Promise((r) => server.getConnections((e, n) => r(n))));
sockets.forEach((s) => s.destroy());
server.close();
});
dropped a connection from 127.0.0.1
open on server: 2
Source: server.maxConnections
The guide also points at the client side of the same coin: agent.maxSockets, agent.maxTotalSockets and agent.maxFreeSockets limit the sockets your own
outbound requests hold open (see Fetch & HTTP Clients).
HTTP request smuggling (CWE-444)
A modern deployment has two HTTP parsers in a row: the proxy and Node.js. Request smuggling exists when they disagree about where a request ends. HTTP/1.1
has two ways to say "here is the body length" (Content-Length and Transfer-Encoding: chunked), and RFC 9112 says a message carrying both might indicate an
attempt at request smuggling and ought to be handled as an error. If the proxy forwards a message that it believes is one request and Node.js believes is two, the second one reaches the application without having
passed the proxy’s checks (authentication, rate limiting, path rules) and may even be glued to another user’s connection.
The official guide is specific about responsibility: it can be a vulnerability in Node.js, in the front-end server or in both, and behaviour that is consistent with the HTTP specification
is not considered a Node.js vulnerability. Its mitigations are to never use the insecureHTTPParser option, to configure the front-end server to normalise ambiguous
requests, to keep watching for new smuggling bugs in both Node.js and the proxy, and to use HTTP/2 end to end where possible.
The default parser is strict. The next example sends four classic ambiguous or malformed requests over a raw socket, first to a default server and then to one created with
insecureHTTPParser: true:
import { createServer } from 'node:http';
import { connect } from 'node:net';
function raw(port, text) {
return new Promise((resolve) => {
const socket = connect(port, '127.0.0.1');
let data = '';
socket.setEncoding('latin1');
socket.on('data', (c) => { data += c; });
socket.on('error', () => {});
socket.on('close', () => resolve(data.split('\r\n')[0] || '(closed)'));
socket.write(text);
setTimeout(() => socket.end(), 300);
});
}
// An ambiguous request: two different ideas of where the body ends.
const clTe =
'POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 6\r\nTransfer-Encoding: chunked\r\n\r\n' +
'0\r\n\r\nG';
const twoLengths =
'POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\nContent-Length: 6\r\n\r\nhello!';
const bareLf =
'GET / HTTP/1.1\nHost: x\nConnection: close\n\n';
const spaceBeforeColon =
'GET / HTTP/1.1\r\nHost: x\r\nTransfer-Encoding : chunked\r\nConnection: close\r\n\r\n';
async function probe(label, options) {
const server = createServer(options, (req, res) => {
req.resume();
req.on('end', () => res.end('handled\n'));
});
server.on('clientError', (err, socket) => {
console.log(` clientError ${err.code}`);
socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const { port } = server.address();
console.log(`${label}`);
console.log(' CL + TE ->', await raw(port, clTe));
console.log(' two Content-Len. ->', await raw(port, twoLengths));
console.log(' bare LF ->', await raw(port, bareLf));
console.log(' "TE : chunked" ->', await raw(port, spaceBeforeColon));
server.close();
}
await probe('default parser', {});
await probe('insecure parser', { insecureHTTPParser: true });
default parser
clientError HPE_INVALID_TRANSFER_ENCODING
CL + TE -> HTTP/1.1 400 Bad Request
clientError HPE_UNEXPECTED_CONTENT_LENGTH
two Content-Len. -> HTTP/1.1 400 Bad Request
clientError HPE_INVALID_VERSION
bare LF -> HTTP/1.1 400 Bad Request
clientError HPE_INVALID_HEADER_TOKEN
"TE : chunked" -> HTTP/1.1 400 Bad Request
insecure parser
clientError HPE_INVALID_EOF_STATE
CL + TE -> HTTP/1.1 200 OK
clientError HPE_UNEXPECTED_CONTENT_LENGTH
two Content-Len. -> HTTP/1.1 400 Bad Request
bare LF -> HTTP/1.1 200 OK
clientError HPE_INVALID_EOF_STATE
"TE : chunked" -> HTTP/1.1 400 Bad Request
Source: --insecure-http-parser and CWE-444
Read the output as a lesson about the parser’s defaults: the default server refused all four, while the lenient one accepted a message carrying both
Content-Length and Transfer-Encoding (the classic smuggling primitive) and a request using bare \n as the line terminator. The --insecure-http-parser flag (and the same-named server
option) is documented to accept, among other things, invalid header values, a message with both framing headers, extra transfer encodings after chunked,
and bare \n separators, and the docs say outright that all of it exposes your application to request smuggling or poisoning. It exists for interoperability with broken
legacy peers; the right fix is to repair the peer or put a normalising proxy in front, not to relax your server.
Practical rules:
-
Never set
insecureHTTPParser, and never pass--insecure-http-parserinNODE_OPTIONS; audit `Dockerfile`s and process managers for it. -
Prefer HTTP/2 between the proxy and Node.js where the proxy supports it: the guide recommends HTTP/2 end to end and disabling HTTP downgrading, because binary framing removes most of the ambiguity of HTTP/1.1 text framing.
-
Keep the proxy and Node.js patched; smuggling bugs are found in both.
-
Do not reuse a client-supplied
Transfer-EncodingorContent-Lengthheader when you proxy a request yourself in application code; let the HTTP client compute them. -
Add your own
'clientError'handler (shown in the last example) so malformed requests get a controlled response, and remember that the handler must close or destroy the socket.
Node.js 26.3.0 (and 24.19.0) added an httpValidation option to http.createServer() and http.request() with the values 'strict' (the default, documented as rejecting non-ASCII
and control characters in header values), 'relaxed' and 'insecure' (equivalent to insecureHTTPParser: true). The docs say it cannot be combined with insecureHTTPParser. A Latin-1 byte in a header value
was accepted by all three values in a quick test on 26.10.0, so treat the 'strict' description as documented rather than observed, and keep relying on a normalising proxy.
Running behind a reverse proxy
The official guide recommends a reverse proxy in front of Node.js: it provides caching, load balancing, IP blocking and TLS termination, and it takes the first hit from slow and malformed clients. Three things change when it is there.
Align keepAliveTimeout with the proxy
If Node.js closes an idle keep-alive connection at the moment the proxy decides to reuse it, the proxy sees a reset and the client receives a 502. The rule is to make
the Node.js idle timeout longer than the proxy’s. AWS documents exactly this for its Application Load Balancer, whose default connection idle timeout is 60 seconds, and recommends that the
application’s idle timeout be larger than the load balancer’s. Hence the value of keepAliveTimeout in the final example is 65 s (60 s plus 5 s of margin), and
keepAliveTimeoutBuffer adds a further second. The response advertises the value in its Keep-Alive header, which the final example prints.
Who is the client? Trusting X-Forwarded-For
Behind a proxy, req.socket.remoteAddress is the proxy. The original client address travels in X-Forwarded-For (each hop appends the address it saw), or in the standardised Forwarded header of RFC 7239,
and the protocol and host in X-Forwarded-Proto and X-Forwarded-Host. All of them are ordinary request headers: anyone who can reach Node.js directly can send
any value. The rule is therefore:
-
trust a forwarding header only when the TCP peer is a proxy you operate;
-
read
X-Forwarded-Forfrom the right (the entries nearest to you were written by your proxies), and stop at the first address that is not one of yours: the left-most entry is whatever the client chose to say; -
make the outermost proxy overwrite (not append to) the client’s forwarding headers if it faces the internet;
-
never use the derived address for authorisation without those guarantees; it is fine for logging and rate-limit keys, and treat IPv4-mapped IPv6 forms such as
::ffff:10.1.2.3as the IPv4 address they represent (an IP-hashing sketch that ignores IPv6 is on the erratum list for Efficient Node.js).
The implementation below uses net.BlockList, which accepts subnets and answers check(); in a quick check on 26.10.0 it also matched the IPv4-mapped form ::ffff:10.1.2.3 against an IPv4 subnet.
import { createServer, request } from 'node:http';
import { BlockList, isIP } from 'node:net';
// Addresses of the reverse proxies we operate (here: the loopback range, for the demo).
function makeClientIp(trustedCidrs) {
const trusted = new BlockList();
for (const cidr of trustedCidrs) {
const [address, prefix] = cidr.split('/');
trusted.addSubnet(address, Number(prefix), isIP(address) === 6 ? 'ipv6' : 'ipv4');
}
const isTrusted = (address) =>
isIP(address) !== 0 && trusted.check(address, isIP(address) === 6 ? 'ipv6' : 'ipv4');
return function clientIp(req) {
const forwarded = (req.headers['x-forwarded-for'] ?? '').split(',').map((h) => h.trim()).filter(Boolean);
const chain = [...forwarded, req.socket.remoteAddress]; // the TCP peer is the last link
// Walk from the right: skip addresses that are our own proxies, stop at the first stranger.
for (let i = chain.length - 1; i >= 0; i--) {
if (!isTrusted(chain[i]) || i === 0) return chain[i];
}
};
}
const clientIp = makeClientIp(['127.0.0.0/8', '::1/128']);
const app = createServer((req, res) => {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ peer: req.socket.remoteAddress, client: clientIp(req) }) + '\n');
});
app.listen(0, '127.0.0.1', () => {
const { port } = app.address();
const get = (xff) => new Promise((resolve) => {
request({ port, headers: xff ? { 'X-Forwarded-For': xff } : {} }, (res) => {
let body = '';
res.on('data', (c) => { body += c; });
res.on('end', () => resolve(body.trim()));
}).end();
});
(async () => {
// The proxy (127.0.0.1 here) appended the address it saw, 203.0.113.7.
console.log(await get('203.0.113.7')); // honest client
console.log(await get('1.2.3.4, 203.0.113.7')); // client lied: the left entry is ignored
console.log(await get('')); // no header: the peer itself
app.close();
})();
});
{"peer":"127.0.0.1","client":"203.0.113.7"}
{"peer":"127.0.0.1","client":"203.0.113.7"}
{"peer":"127.0.0.1","client":"127.0.0.1"}
Source: net.BlockList
Replacing the trusted list with one that does not contain the peer (for example ['10.0.0.0/8'] while connecting from 127.0.0.1) made all three lines print "client":"127.0.0.1":
a direct caller cannot spoof its address.
trust proxy in Express and Fastify
Frameworks package the same logic. In Express the setting is trust proxy; it affects req.ip, req.ips, req.protocol and the hostname (derived from X-Forwarded-Host; the example prints req.host). Fastify’s option
is trustProxy and exposes request.ip, request.ips, request.protocol and request.host. The accepted values are the part to read carefully:
| Value | Express 5 | Fastify 5 |
|---|---|---|
|
The client is the left-most |
Trust all proxies, with the same caveat. |
Address, subnet or list |
Trusted proxies; also named subnets |
A string, a comma-separated string or an array of addresses and CIDR ranges. |
Number (hop count) |
Allowed: the client is at most |
Refused: the docs say hop-count-only trust is disabled because it cannot validate the immediate peer and lets direct clients spoof the headers. Observed: |
Function |
Custom trust function. |
Custom function receiving the address (and hop); it must validate the address. |
import express from 'express';
import { request } from 'node:http';
const app = express();
app.set('trust proxy', 'loopback'); // trust only proxies at these addresses (also: 1, CIDR lists, a function)
app.get('/', (req, res) => {
res.json({ ip: req.ip, ips: req.ips, protocol: req.protocol, host: req.host });
});
const server = app.listen(0, '127.0.0.1', () => {
const { port } = server.address();
const headers = {
'X-Forwarded-For': '1.2.3.4, 203.0.113.7', // "1.2.3.4" was supplied by the client
'X-Forwarded-Proto': 'https',
'X-Forwarded-Host': 'books.example.com',
};
request({ port, headers }, (res) => {
let body = '';
res.on('data', (c) => { body += c; });
res.on('end', () => { console.log(body); server.close(); });
}).end();
});
{"ip":"203.0.113.7","ips":["203.0.113.7"],"protocol":"https","host":"books.example.com"}
Source: Express behind proxies
import Fastify from 'fastify';
const app = Fastify({ trustProxy: '127.0.0.1' }); // an address or CIDR list; a bare hop count is refused
app.get('/', async (req) => ({ ip: req.ip, ips: req.ips, protocol: req.protocol, host: req.host }));
await app.listen({ port: 0, host: '127.0.0.1' });
const { port } = app.server.address();
const res = await fetch(`http://127.0.0.1:${port}/`, {
headers: {
'x-forwarded-for': '1.2.3.4, 203.0.113.7',
'x-forwarded-proto': 'https',
'x-forwarded-host': 'books.example.com',
},
});
console.log(await res.json());
await app.close();
{
ip: '203.0.113.7',
ips: [ '127.0.0.1', '203.0.113.7' ],
protocol: 'https',
host: 'books.example.com'
}
Note the difference in ips (observed): Express omits the trusted proxy itself, Fastify keeps it. In both, the client-supplied 1.2.3.4 is ignored. The same pattern applies to NestJS, which sits on Express or Fastify
(see Fastify & HTTP Platforms). For TLS, terminate it at the proxy and let X-Forwarded-Proto tell the app
whether the original request was https (needed for Secure cookies and redirects), or terminate in Node.js itself as described in HTTPS, TLS & HTTP/2.
Putting it together
The factory below applies the whole checklist to a plain node:http server, and is the version the Bookshelf API grows from in
Building a REST API. The numbers are starting points: derive them from your slowest legitimate client, your proxy’s idle timeout and your
traffic.
import { createServer, get } from 'node:http';
export function createHardenedServer(handler, { behindProxyIdleSeconds = 60 } = {}) {
const server = createServer({
headersTimeout: 15_000, // slowloris: headers must arrive quickly
requestTimeout: 60_000, // whole request, body included
keepAliveTimeout: (behindProxyIdleSeconds + 5) * 1000, // outlive the proxy's idle timeout
maxHeaderSize: 16 * 1024, // the default, stated explicitly
connectionsCheckingInterval: 5_000,
}, handler);
server.maxHeadersCount = 100;
server.maxRequestsPerSocket = 1_000;
server.maxConnections = 10_000;
// Malformed or ambiguous requests land here. The socket must be closed or destroyed by us.
server.on('clientError', (err, socket) => {
if (err.code === 'ECONNRESET' || !socket.writable) return;
const status = err.code === 'HPE_HEADER_OVERFLOW' ? '431 Request Header Fields Too Large' : '400 Bad Request';
socket.end(`HTTP/1.1 ${status}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
});
return server;
}
if (import.meta.main) {
const server = createHardenedServer((req, res) => res.end('ok\n'));
server.listen(0, '127.0.0.1', () => {
get({ port: server.address().port }, (res) => {
console.log(res.statusCode, res.headers['keep-alive']);
res.resume();
res.on('end', () => server.close(() => console.log('closed')));
});
});
}
200 timeout=65, max=1000
closed
Source: http.createServer()
Pitfalls
| Mistake | Fix |
|---|---|
Relying on the defaults without knowing them |
Print |
Setting |
Raise it for the upload route or move uploads to a separate server; |
Changing server properties after |
They apply to new connections only; set them before listening. |
|
Make it longer (for ALB, more than 60 s) to avoid sporadic |
Believing |
Count bytes as they arrive; chunked requests have no |
Reading |
The left-most entry is client-controlled; walk from the right and skip your own proxies. |
Express |
Use an address or CIDR list and keep the app on a private network. |
|
Fix the client or normalise at the proxy. |
A |
The handler must close or destroy the socket, and should not write to one that is no longer writable. |
What changed recently
| Version | Change |
|---|---|
v12.0.0 |
Header overflow answers |
v13.0.0 |
The default |
v13.13.0 |
The default maximum header size rose from 8 KiB to 16 KiB. |
v16.10.0 |
|
v18.0.0 |
|
v18.14.0 and v19.4.0 |
|
v19.0.0 |
|
v24.6.0 and v22.19.0 |
|
v24.9.0 and v22.21.0 |
|
v25.1.0 and v24.12.0 |
|
v26.3.0 and v24.19.0 |
|
Pending |
The development docs show |
Express and Fastify have moved too: Express 5 documents hop-count and address-list settings for trust proxy side by side, while Fastify 5 (as of 5.12.5) refuses a bare number for trustProxy.
Re-check both pages when upgrading, because a changed default here is a security change.
Where the books are outdated
-
Efficient Node.js (Samer Buna, O’Reilly, 2025) and Node.js Projects (Jonathan Wexler, O’Reilly, 2025) both build servers on
node:httpor Fastify, but neither develops server timeouts, request smuggling or the trust model for forwarding headers, which is why this page leans on the official docs. Treat any example in them that readsX-Forwarded-Foror an IP address as a starting point to be hardened, as above, and check IPv6 handling; Efficient Node.js has an IP-hash sketch that breaks on IPv6 addresses. -
Both books were written for Node.js 22-era behaviour (Efficient Node.js asks for Node.js 20 or higher, Node.js Projects for 22.16 or later), so the timeout defaults described above already applied to them. Since then
keepAliveTimeoutBufferandhttpValidationhave been added, and Node.js 20 is end of life, so thenode:20base image in Node.js Projects should be moved to a supported line (see Release Lines & LTS). -
The Fastify code in Node.js Projects targets Fastify 4 and 5; Fastify 4 is end of life, and in Fastify 5.12.5 the
trustProxyhop-count form is refused andrequestTimeoutdefaults to0.