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.

Table 1. Attacks on an HTTP server and the knob that answers each
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.

headersTimeout, requestTimeout, connectionsCheckingInterval, maxConnections, a reverse proxy in front.

Slow body ("slow POST")

Sends complete headers, then trickles the body one byte at a time.

requestTimeout; a body size limit; reading the body with a cap.

Oversized request

Sends huge headers, thousands of headers, or an enormous body.

maxHeaderSize, maxHeadersCount, a body limit (413), a proxy-level limit.

Idle connection hoarding

Opens keep-alive connections and never uses them.

keepAliveTimeout, server.timeout, maxRequestsPerSocket, maxConnections.

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 insecureHTTPParser), a proxy that normalises ambiguous requests, HTTP/2 end to end.

Spoofed client address

Sends its own X-Forwarded-For to hide, or to impersonate an allow-listed address.

Trusting forwarding headers only from proxies you operate (trust proxy).

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.

Table 2. Server-side timeouts and limits (defaults observed on 26.10.0, 24.21.0 and 22.23.3)
Property or option Default What it controls, and history

server.headersTimeout

60000 (the minimum of requestTimeout and 60 s)

Time allowed to receive the complete header block. On expiry the server answers 408 without calling your handler and closes the connection. Added v11.3.0; the default became the minimum of 60 s and requestTimeout in v19.4.0 and v18.14.0.

server.requestTimeout

300000 (5 minutes)

Time allowed to receive the entire request, headers and body. On expiry the server answers 408 and closes the connection. Added v14.11.0; the default changed from no timeout to 300 s in v18.0.0.

server.timeout

0 (none)

Socket inactivity timeout. Emits a 'timeout' event on the server with the socket; if you attach a listener, the docs say the timeout must be handled explicitly. Applies only to connections accepted after it is set. The default changed from 120 s to 0 in v13.0.0.

server.keepAliveTimeout

5000 (5 s)

How long an idle keep-alive socket is kept after the last response. 0 disables it. See the note on its default below.

server.keepAliveTimeoutBuffer

1000 (1 s)

Extra time added to keepAliveTimeout to reduce ECONNRESET races (the real socket timeout is the sum). Added v24.6.0 and v22.19.0.

connectionsCheckingInterval (option)

30000 (30 s)

How often the server scans for connections whose headersTimeout or requestTimeout has expired. Supported since v18.0.0.

server.maxHeadersCount

null when read; an effective limit of about 1000

Maximum number of header fields; 0 removes the limit. The docs state a default of 1000; the property reads null when unset, and the observed cut-off was between 992 and 1100 headers.

maxHeaderSize (option) and http.maxHeaderSize

16384 (16 KiB)

Maximum size of the request header block in bytes; the global default is set with the --max-http-header-size flag. The default rose from 8 KiB to 16 KiB in v13.13.0. Since v12.0.0 an overflow answers 431.

server.maxRequestsPerSocket

0 (no limit)

Requests served on one keep-alive socket before the server sets Connection: close; further requests on that socket get 503. Added v16.10.0.

server.maxConnections (from node:net)

none

Above this number the server drops new connections and emits a 'drop' event instead.

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.

A server that gives clients one second to finish their headers
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
});
Output (identical on Node.js 26, 24 and 22)
closed after ~1 s
HTTP/1.1 408 Request Timeout

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.

requestTimeout also protects handlers that are already reading the body
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);
  }
});
Output (identical on Node.js 26, 24 and 22)
handler saw: ECONNRESET
client got: HTTP/1.1 408 Request Timeout

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.

Header limits in action: a 2 KiB header budget and at most 10 header fields
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();
});
Output (identical on Node.js 26, 24 and 22)
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

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).

A JSON body reader with a hard byte limit
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();
});
Output (identical on Node.js 26, 24 and 22)
small  : 200 {"keys":["title"]}
large  : 413 {"error":"declared body too large"}
chunked: 413 {"error":"body too large"}

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):

Table 3. Body limit and timeout defaults in the two common frameworks
Framework Body limit Server timeouts

Express 5

express.json() and express.urlencoded() default to '100kb'; a larger body becomes a 413 PayloadTooLargeError.

app.listen() returns the plain node:http server, so Node.js defaults apply and every property on this page can be set on it.

Fastify 5

bodyLimit defaults to 1 MiB (1048576) and is configured per server or per route.

The requestTimeout option defaults to 0 (no limit), which replaces Node’s 300 s default (observed: a fresh Fastify server reports requestTimeout of 0). keepAliveTimeout defaults to 72 s. Set requestTimeout yourself.

Setting the limits explicitly in Express 5 and Fastify 5
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();
Output (stdout; Express also logs the rejected body’s 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.

Capping concurrent connections and observing the drops
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();
});
Output (identical on Node.js 26, 24 and 22)
dropped a connection from 127.0.0.1
open on server: 2

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:

Strict versus insecure parser against four ambiguous requests
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 });
Output (identical on Node.js 26, 24 and 22)
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

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-parser in NODE_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-Encoding or Content-Length header 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-For from 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.3 as 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.

Deriving the client address from X-Forwarded-For with a list of trusted proxies
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();
  })();
});
Output (identical on Node.js 26, 24 and 22)
{"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:

Table 4. How trust proxy values behave (Express 5.2.1 and Fastify 5.12.5)
Value Express 5 Fastify 5

true

The client is the left-most X-Forwarded-For entry. Only safe if the last trusted proxy overwrites the forwarding headers.

Trust all proxies, with the same caveat.

Address, subnet or list

Trusted proxies; also named subnets loopback, linklocal and uniquelocal. The nearest untrusted address is the client.

A string, a comma-separated string or an array of addresses and CIDR ranges.

Number (hop count)

Allowed: the client is at most n hops away. The docs warn that it is unsafe if the client can reach the app through a shorter path than the proxies.

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: trustProxy: 1 left request.ip as the socket address.

Function

Custom trust function.

Custom function receiving the address (and hop); it must validate the address.

Express 5: trust only loopback proxies
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();
});
Output (identical on Node.js 26, 24 and 22)
{"ip":"203.0.113.7","ips":["203.0.113.7"],"protocol":"https","host":"books.example.com"}
Fastify 5: the same with a trusted address
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();
Output (identical on Node.js 26, 24 and 22)
{
  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.

A hardened server factory
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')));
    });
  });
}
Output (identical on Node.js 26, 24 and 22)
200 timeout=65, max=1000
closed

Pitfalls

Table 5. Common mistakes and their fixes
Mistake Fix

Relying on the defaults without knowing them

Print server.headersTimeout, requestTimeout and keepAliveTimeout at start-up, or set them explicitly; Fastify’s requestTimeout default of 0 silently removes Node’s 300 s.

Setting requestTimeout or headersTimeout to 0 to "fix" slow uploads

Raise it for the upload route or move uploads to a separate server; 0 disables the slowloris defence.

Changing server properties after listen() and expecting existing connections to follow

They apply to new connections only; set them before listening.

keepAliveTimeout shorter than the proxy’s idle timeout

Make it longer (for ALB, more than 60 s) to avoid sporadic 502/ECONNRESET.

Believing Content-Length to enforce the body limit

Count bytes as they arrive; chunked requests have no Content-Length.

Reading X-Forwarded-For from the left

The left-most entry is client-controlled; walk from the right and skip your own proxies.

Express trust proxy hop counts with an app that is also reachable directly

Use an address or CIDR list and keep the app on a private network.

insecureHTTPParser to talk to a legacy client

Fix the client or normalise at the proxy.

A 'clientError' handler that forgets the socket

The handler must close or destroy the socket, and should not write to one that is no longer writable.

What changed recently

Table 6. Server-hardening changes by Node.js version
Version Change

v12.0.0

Header overflow answers 431 Request Header Fields Too Large by default.

v13.0.0

The default server.timeout changed from 120 s to 0 (no socket timeout).

v13.13.0

The default maximum header size rose from 8 KiB to 16 KiB.

v16.10.0

server.maxRequestsPerSocket added.

v18.0.0

requestTimeout now defaults to 300 s (previously no limit); requestTimeout, headersTimeout, keepAliveTimeout and connectionsCheckingInterval became http.createServer() options.

v18.14.0 and v19.4.0

headersTimeout defaults to the minimum of 60 s and requestTimeout.

v19.0.0

server.close() also closes idle keep-alive connections; closeIdleConnections() is no longer required for that.

v24.6.0 and v22.19.0

server.keepAliveTimeoutBuffer added (default 1000 ms).

v24.9.0 and v22.21.0

shouldUpgradeCallback option: decide which Upgrade attempts raise the 'upgrade' event.

v25.1.0 and v24.12.0

optimizeEmptyRequests option added.

v26.3.0 and v24.19.0

httpValidation option ('strict', 'relaxed', 'insecure').

Pending

The development docs show keepAliveTimeout moving from 5 s to 65 s; not in 26.10.0, where the value measured 5 s.

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:http or 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 reads X-Forwarded-For or 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 keepAliveTimeoutBuffer and httpValidation have been added, and Node.js 20 is end of life, so the node:20 base 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 trustProxy hop-count form is refused and requestTimeout defaults to 0.