In short

Put a CDN or cloud WAF with rate-based rules in front, and make the origin reachable only through it. At every proxy and server, bound each phase of a request: time to receive headers, time between body reads and for the whole request, idle keep-alive time, header count and size, and body size. Limit requests and concurrent connections per client and per expensive route, and answer with 429. Keep your HTTP/2 implementation patched for Rapid Reset (CVE-2023-44487), CONTINUATION floods, MadeYouReset (CVE-2025-8671) and the 2026 HPACK bomb, and cap concurrent streams. When demand still exceeds capacity, shed the cheapest-to-refuse traffic first with 503 and Retry-After instead of letting every request slow down.

  • Slowloris holds connections open by sending headers slowly; slow POST does the same with the body. Each costs the attacker almost nothing and costs you a connection.
  • Timeouts differ: nginx's client_header_timeout bounds the whole header, but client_body_timeout only bounds the gap between reads. In our test a body trickled one byte every 3 seconds stayed open for 45 seconds against a 10-second setting.
  • Node.js and Envoy can bound the whole request. With requestTimeout or request_timeout at 30 seconds, both answered the same trickled body with 408 after 30 to 31 seconds.
  • nginx's limit_conn counts only connections whose headers are complete. Twenty-five half-sent requests from one address all stayed open under a limit of 20 until the header timeout closed them.
  • HTTP/2 lets one connection carry many requests. Rapid Reset peaked at 398 million requests per second against Google in 2023, and new HTTP/2 attacks followed in 2024, 2025 and 2026.
  • Rate limits are only as good as their key: IP addresses spread across a botnet, so add per-session, per-API-key and per-route limits, and accept a forwarded client IP only from your CDN.

Verify it

  1. Run the slow-client probe against each listener: a trickled header must be closed within your header timeout, and a trickled body within your total request timeout or connection limit.
  2. Send a burst of requests from one address to a rate-limited route and confirm the excess gets 429, and that a second address is unaffected.
  3. Hold more slow uploads than the per-client connection limit and confirm the extra connections are refused.
  4. Check every HTTP/2 terminator's version against the fixes for CVE-2023-44487, the CONTINUATION CVEs, CVE-2025-8671 and CVE-2026-49975 or CVE-2026-47774.
  5. From outside the CDN, request the origin directly and confirm it refuses the connection or the request.
  6. In a load test, push past capacity and confirm the service returns 503 with Retry-After quickly instead of timing out, and recovers when the load stops.
Applies to
  • nginx 1.30.5 stable (tested); max_headers needs 1.29.8 or later
  • Envoy 1.39.3 (tested)
  • Node.js 24 LTS and 26 (tested on 26.3.0)

Internet-facing HTTP/1.1 and HTTP/2 services behind nginx, Envoy, a CDN or a cloud load balancer, and Node.js servers. Network-layer floods need your provider's or ISP's DDoS protection and are out of scope.

Fix it in your stack

Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

Learning goals and prerequisitesAbout 33 minutes: 9 to read and 24 for the exercise

What you will learn

  • Explain how slow clients, HTTP floods and HTTP/2 stream abuse exhaust a server.
  • Configure header, body and idle timeouts, size limits and per-client limits in nginx, Envoy and Node.js.
  • Check whether your HTTP/2 stack has the fixes for Rapid Reset and the attacks that followed it.
  • Combine edge rate limiting with origin protection and load shedding.

Before you start

Platform, SRE and backend engineers who run internet-facing HTTP services, proxies or CDNs

  • Read nginx or Envoy configuration and HTTP request structure.
  • Know where TLS terminates and which proxies sit in front of your service.

Refresh first: Bound API work, not just request counts

Go to the guided practice

What an application-layer attack exhausts

A network flood fills your pipe; an application-layer (layer 7) attack sends requests that look valid and makes your servers spend their limited resources on them. Those resources are connections and worker slots, memory for buffered headers and bodies, CPU for TLS handshakes, routing and decompression, and the capacity of whatever sits behind the proxy: databases, search and payment providers. CAPEC calls the two main shapes an HTTP flood (CAPEC-488) and an HTTP DoS that keeps sessions alive (CAPEC-469).

Slow attacks keep connections busy for little cost. Slowloris opens many connections and sends header lines slowly, slow POST declares a large body and sends it a byte at a time, and slow read accepts the response slowly. Floods go the other way: a botnet sends many cheap-looking requests, often to the most expensive endpoint it can find, such as search, login or report export, spread over thousands of addresses so that no single address looks abusive.

HTTP/2 multiplies the work one connection can create

HTTP/2 runs many request streams over one connection, and servers cap concurrent streams to bound the work. Rapid Reset (CVE-2023-44487), disclosed on 10 October 2023, sidestepped the cap: the client opens a stream and resets it at once, so the stream never counts as concurrent but the server has already started the request. Google reported a peak of 398 million requests per second, Cloudflare 201 million and AWS 155 million. Later attacks found other ways to make a server work on streams or headers the protocol considers finished or bounded.

Fixes live in the implementation, so patching is the main defense; configuration helps. nginx's fix limits new streams per event loop to twice http2_max_concurrent_streams, and its default of 1,000 requests per connection already capped the attack. Envoy 1.27.1 added premature-reset detection: by default it closes a connection once half of its streams were reset less than a second after opening, checked after 500 streams or as soon as 250 such resets add up. Node.js added streamResetBurst and streamResetRate in 22.10.0. If you cannot patch a component, disable HTTP/2 on it, or put it behind a terminator that is patched.

HTTP/2 multiplies the work one connection can create
AttackDisclosedWhat it abusesFixes to check for
Rapid Reset, CVE-2023-44487October 2023Streams reset by the client before the concurrency limit counts themnginx 1.25.3; Envoy 1.27.1 (GHSA-jhv4-f7mr-xx76)
CONTINUATION flood, VU#421644April 2024Header blocks that never end, buffered or decoded without limitPer-project CVEs: Apache httpd CVE-2024-27316, Node.js CVE-2024-27983, Go CVE-2023-45288, nghttp2 CVE-2024-28182, Envoy CVE-2024-27919 and CVE-2024-30255
MadeYouReset, CVE-2025-8671 (VU#767506)August 2025Malformed frames that make the server reset streams it keeps processingApache Tomcat CVE-2025-48989, Netty CVE-2025-55163, F5 BIG-IP CVE-2025-54500
HTTP/2 bomb: HPACK amplification plus a zero flow-control windowJune 2026Small compressed headers that expand into large allocations held openApache httpd 2.4.68 (CVE-2026-49975); Envoy 1.35.11, 1.36.7, 1.37.3 and 1.38.1 (CVE-2026-47774); nginx 1.29.8 and 1.30.0 add max_headers

Bound every phase of a request

Each phase needs its own limit: receiving the headers, receiving the body, the whole request, sending the response and idling between keep-alive requests. Servers name and scope these timeouts differently, and the difference decides whether a slow client is stopped. We ran the same three slow clients against nginx 1.30.5, Envoy 1.39.3 and Node.js 26.3.0, configured as in the sections below, with a 10-second header limit and, where available, a 30-second request limit.

nginx logged the header timeout as a 408 with no bytes sent, and the trickled body produced no log line at all while it was open. Watch active connections, connections still reading a request, and 408 and 429 counts, not just request rates.

Bound every phase of a request
Slow clientnginx 1.30.5Envoy 1.39.3Node.js 26.3.0
Request line, then nothingClosed at 10.0 s with no response; logged as 408408 at 11.0 s (request_headers_timeout)408 at 10.0 s (headersTimeout)
One header line every 3 sClosed at 10.0 s: the timeout covers the whole header408 at 11.0 s408 at 10.1 to 11.0 s
Complete headers, then one body byte every 3 sStill open after 45 s: client_body_timeout restarts on every read408 at 31.0 s (request_timeout)408 at 30.1 to 31.0 s (requestTimeout)

nginx: timeouts, sizes and per-client limits

nginx has no total request timeout, so pair client_body_timeout with limit_conn, which caps how many connections one key can have open with a request in progress. In our test, 25 slow uploads from one address to a route without a request-rate limit left 20 open and refused 5 with 429. limit_conn counts a connection only once its whole header has arrived, so header trickling is bounded by client_header_timeout alone: 25 half-sent requests all stayed open until it closed them.

limit_req applies a leaky bucket per key. A burst of 60 simultaneous requests from one address to the default route got 21 responses, one plus the burst of 20, and 39 rejections with 429. On /login, 6 of 20 went through and 14 got 429. nginx returns 503 for both limits unless you set the status codes, and 503 tells clients and monitors the wrong thing. We tested this file with the TLS lines replaced by a plain-HTTP listener so the probe could connect; the TLS version passes nginx -t.

Configuration/etc/nginx/nginx.confnginx
# nginx 1.30.5: limits for an internet-facing reverse proxy (tested with the probe below).
worker_processes auto;
events { worker_connections 4096; }

http {
    # Bound each phase of a request.
    client_header_timeout 10s;     # the whole request header (default 60s)
    client_body_timeout   10s;     # only the gap between two body reads (default 60s)
    send_timeout          10s;     # the gap between two writes to the client (default 60s)
    keepalive_timeout     15s;     # idle keep-alive connections (default 75s)
    keepalive_requests    1000;    # per connection, HTTP/1.1 and HTTP/2 (default)
    reset_timedout_connection on;  # release the socket of a timed-out client at once

    # Size limits.
    client_max_body_size        1m;    # default; raise it per upload route only
    large_client_header_buffers 4 8k;  # default
    max_headers                 100;   # nginx 1.29.8 or later (default 1000)

    # Per-client request rate and concurrent requests, answered with 429 instead of 503.
    limit_req_zone  $binary_remote_addr zone=per_ip:10m rate=10r/s;
    limit_req_zone  $binary_remote_addr zone=login:10m  rate=1r/s;
    limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m;
    limit_req_status  429;
    limit_conn_status 429;

    upstream app {
        server app:8080;
        keepalive 32;
    }

    server {
        listen 443 ssl;
        http2 on;
        http2_max_concurrent_streams 64;   # default 128
        ssl_certificate     /etc/nginx/tls/server.crt;
        ssl_certificate_key /etc/nginx/tls/server.key;

        limit_conn conn_per_ip 20;         # counts requests whose header has arrived

        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_connect_timeout 2s;
        proxy_read_timeout    10s;

        location / {
            limit_req zone=per_ip burst=20 nodelay;
            proxy_pass http://app;
        }

        location /upload {                 # limit_conn only: slow uploads are capped per client
            proxy_pass http://app;
        }

        location = /login {
            limit_req zone=login burst=5 nodelay;
            proxy_pass http://app;
        }
    }
}

Envoy: edge defaults are not on by default

Envoy's defaults suit a service mesh, not the internet, and its edge guidance lists what to change. request_headers_timeout and request_timeout are off by default, the stream idle timeout is 5 minutes, and HTTP/2 allows 1,024 concurrent streams per connection where the edge guidance recommends 100. The configuration below passed envoy --mode validate and answered our slow clients with 408, rejected a header containing an underscore with 400, and let 102 of a 300-request burst through its local token bucket.

The local rate limiter here is one bucket for the whole Envoy instance, not one per client; use descriptors or the global rate limit service to key it on client, route or API key. The overload manager stops accepting requests near the heap limit and caps active connections across all listeners, which keeps Envoy alive long enough to shed load rather than crash.

Configurationenvoy.yamlYAML
overload_manager:
  refresh_interval: 0.25s
  resource_monitors:
    - name: envoy.resource_monitors.global_downstream_max_connections
      typed_config:
        "@type": type.googleapis.com/envoy.extensions.resource_monitors.downstream_connections.v3.DownstreamConnectionsConfig
        max_active_downstream_connections: 50000
    - name: envoy.resource_monitors.fixed_heap
      typed_config:
        "@type": type.googleapis.com/envoy.extensions.resource_monitors.fixed_heap.v3.FixedHeapConfig
        max_heap_size_bytes: 1073741824 # 1 GiB: tune to the container limit
  actions:
    - name: envoy.overload_actions.stop_accepting_requests
      triggers:
        - name: envoy.resource_monitors.fixed_heap
          threshold: {value: 0.95}
static_resources:
  listeners:
    - name: ingress
      address: {socket_address: {address: 0.0.0.0, port_value: 10000}}
      per_connection_buffer_limit_bytes: 32768
      filter_chains:
        - filters:
            - name: envoy.filters.network.http_connection_manager
              typed_config:
                "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
                stat_prefix: ingress_http
                use_remote_address: true
                request_headers_timeout: 10s # whole header block; off by default
                request_timeout: 30s         # whole request; off by default
                stream_idle_timeout: 60s     # default 5 min
                common_http_protocol_options:
                  idle_timeout: 300s
                  max_headers_count: 100
                  headers_with_underscores_action: REJECT_REQUEST
                http2_protocol_options:
                  max_concurrent_streams: 100
                  initial_stream_window_size: 65536
                  initial_connection_window_size: 1048576
                route_config:
                  virtual_hosts:
                    - name: app
                      domains: ["*"]
                      routes:
                        - match: {prefix: "/"}
                          route: {cluster: app, timeout: 15s}
                http_filters:
                  - name: envoy.filters.http.local_ratelimit
                    typed_config:
                      "@type": type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit
                      stat_prefix: http_local_rate_limiter
                      token_bucket:
                        max_tokens: 100
                        tokens_per_fill: 100
                        fill_interval: 1s
                      filter_enabled:
                        runtime_key: local_rate_limit_enabled
                        default_value: {numerator: 100, denominator: HUNDRED}
                      filter_enforced:
                        runtime_key: local_rate_limit_enforced
                        default_value: {numerator: 100, denominator: HUNDRED}
                  - name: envoy.filters.http.router
                    typed_config:
                      "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
  clusters:
    - name: app
      type: STRICT_DNS
      per_connection_buffer_limit_bytes: 32768
      load_assignment:
        cluster_name: app
        endpoints:
          - lb_endpoints:
              - endpoint: {address: {socket_address: {address: app, port_value: 8080}}}

Node.js: set the server limits explicitly

Node's HTTP server enforces headersTimeout (60 seconds) and requestTimeout (300 seconds) by default, but checks them only every connectionsCheckingInterval, 30 seconds by default, so a deadline can be overshot by up to that interval. Lower all three for a server that faces clients directly. For HTTP/2, set maxConcurrentStreams: Node sets no default limit, which in theory allows 2^32-1 concurrent streams per session.

In front of a Node service, a proxy should still terminate client connections and enforce the same limits, but these settings keep the process safe when someone reaches it directly or the proxy is misconfigured.

Configurationserver.mjsJavaScript
import http from 'node:http';
import http2 from 'node:http2';
import {readFileSync} from 'node:fs';

const limits = {
  headersTimeout: 10_000,             // whole header block (default 60 s)
  requestTimeout: 30_000,             // whole request, headers and body (default 300 s)
  keepAliveTimeout: 5_000,            // idle keep-alive socket (default 5 s)
  connectionsCheckingInterval: 1_000, // how often the two deadlines above are checked (default 30 s)
  maxHeaderSize: 16_384,              // bytes (default 16 KiB)
};

function handler(req, res) {
  let size = 0;
  req.on('data', chunk => {
    size += chunk.length;
    if (size > 1_048_576) req.destroy(); // 1 MiB body cap
  });
  req.on('end', () => res.end('ok\n'));
}

const server = http.createServer(limits, handler);
server.maxHeadersCount = 100;       // default 1000 (2000 in the Node.js 24 docs)
server.maxRequestsPerSocket = 1000; // default 0, unlimited
server.listen(8080);

// HTTP/2 over TLS: cap concurrent streams; Node sets no limit by default.
const h2 = http2.createSecureServer(
  {
    key: readFileSync('tls/server.key'),
    cert: readFileSync('tls/server.crt'),
    settings: {maxConcurrentStreams: 100},
    maxSessionInvalidFrames: 1000,  // default
    maxSessionRejectedStreams: 100, // default
  },
  handler,
);
h2.listen(8443);

Absorb floods at the edge and lock the origin behind it

A CDN or cloud WAF spreads a flood across a network much larger than yours and can rate limit before traffic reaches you. Rate-based rules differ by provider. AWS WAF counts over a 60, 120, 300 or 600-second window with a minimum limit of 10 and can aggregate by IP, forwarded IP or custom keys. Google Cloud Armor can throttle or ban a client, keyed on IP, header, cookie, path, TLS fingerprint and more. Azure Front Door counts per socket IP over one or five minutes and can let some requests over a low threshold through, because counters are kept per edge server.

The edge protects nothing if attackers can reach the origin directly. Accept origin traffic only from the CDN, through provider IP lists, a private connection or an authenticated origin header, and trust X-Forwarded-For only from the CDN; the API gateway inventory guide at /domains/runtime/api-security/api-gateway-inventory/ covers trusted proxies. Rate limit on the identity that costs you money, not only on IP addresses: per session, per API key and per tenant on expensive routes, as the API resource limits guide at /domains/runtime/api-security/resource-consumption-limits/ shows.

Shed load before everything slows down

When demand exceeds capacity, every request slowing down is the worst outcome: clients time out, retry and multiply the load. Refuse some requests quickly instead. Put a concurrency limit in front of each expensive dependency, reject excess work with 503 and a Retry-After header (RFC 9110) or 429 for per-client limits (RFC 6585), and make clients retry with exponential backoff and jitter. Google's SRE book describes this in its chapters on handling overload and cascading failures.

Decide in advance what to keep: health checks, logins of existing users, checkout, paying tenants. Then serve the rest from cache, degrade it, or refuse it. Cache public responses at the edge so a flood of identical requests never reaches the origin, and move expensive work such as exports and reports to bounded queues, as the bounded background work guide at /domains/runtime/availability/bounded-background-work/ describes.

Test the limits before an attacker does

The probe below opens one HTTP/1.1 connection and sends a request a piece at a time, which is how we produced the results above. Point it at a staging listener, not at production, and not through a provider's network without following its testing policy. For HTTP/2, use a load generator that supports it, such as h2load, and watch memory, active streams and connection counts on the server.

Testslow-client.mjsJavaScript
// slow-client.mjs: send an HTTP/1.1 request a piece at a time and report how the server ends it.
// node slow-client.mjs <host> <port> header|body [interval-ms]
import net from 'node:net';

const [host, port, mode = 'header', interval = '3000'] = process.argv.slice(2);
const pieces =
  mode === 'header'
    ? ['GET / HTTP/1.1\r\nHost: test\r\n', ...Array.from({length: 40}, (_, i) => `X-Pad-${i}: a\r\n`)]
    : ['POST /upload HTTP/1.1\r\nHost: test\r\nContent-Length: 1000\r\n\r\n', ...Array(40).fill('a')];

const started = Date.now();
const socket = net.connect(Number(port), host);
let reply = '';
let sent = 0;
const timer = setInterval(() => sent < pieces.length && socket.write(pieces[sent++]), Number(interval));
socket.on('connect', () => socket.write(pieces[sent++]));
socket.on('data', chunk => (reply += chunk));
socket.on('close', () => {
  clearInterval(timer);
  const seconds = ((Date.now() - started) / 1000).toFixed(1);
  console.log(`closed after ${seconds}s, ${sent} pieces sent, reply: ${reply.split('\r\n')[0] || '(none)'}`);
});
setTimeout(() => {
  console.log(`still open after 45s, ${sent} pieces sent`);
  socket.destroy();
}, 45_000).unref();

Pitfalls

  • Trusting a per-read body timeout to stop slow POST.
  • Rate limiting on the client IP header without stripping it at the edge, so any client can choose its own key.
  • Answering limits with 503, which monitors read as an outage and clients retry at once.
  • Raising keepalive_requests or HTTP/2 stream limits for benchmarks and leaving them raised.
  • An origin that answers anyone who finds its address, which makes the CDN optional.
  • Retries without backoff in your own clients and services, which turn a partial outage into a full one.

Guided practice

A public API behind nginx slows down for everyone. Use the observations below to decide what is happening and which changes to make, in order.

Supplied scenario material
Plain text
Observed over 10 minutes (nginx 1.30.5, HTTP/1.1 and HTTP/2 enabled, no CDN)
  Active connections: 180 normally, now 9,400; 8,900 in the "reading" state
  Requests per second: 1,200 normally, now 1,350
  Upstream (Node.js) CPU: 35%; database CPU: 40%
  Top client addresses: 2,700 distinct; none above 0.5% of connections
  Sample from a packet capture: POST /v1/uploads with Content-Length: 104857600, one byte every 8 seconds
nginx configuration today
  client_header_timeout 60s; client_body_timeout 60s; client_max_body_size 100m;
  no limit_conn; limit_req zone=per_ip rate=50r/s on /v1/search only
Origin address: published in DNS; the API also accepts traffic directly on its public IP
  1. 1. Name the attack

    Explain why requests per second barely changed while the service slowed down.

    Expected result and reasoning

    It is a slow POST attack: thousands of connections sit in the reading state with complete headers, sending a body a byte every 8 seconds. That is under the 60-second client_body_timeout, which only measures the gap between reads, so no timeout fires and connections, not requests, run out.

  2. 2. Choose immediate changes

    Pick the nginx changes that relieve this attack today.

    Expected result and reasoning

    Cut client_max_body_size to what uploads need and give the upload route its own lower limit. Add limit_conn per address with a status of 429, lower client_body_timeout, and enable reset_timedout_connection. If uploads must stay large, move them to direct-to-storage uploads with pre-signed URLs.

  3. 3. Account for the spread

    Why might a per-address connection limit not be enough here?

    Expected result and reasoning

    The traffic comes from 2,700 addresses with none above 0.5%, so a per-address limit of a few connections still lets thousands through. Add a CDN or proxy that buffers request bodies before they reach the origin, require authentication on the upload route so limits can key on the account, and cap total concurrent uploads.

  4. 4. Close the bypass

    What has to change about the origin?

    Expected result and reasoning

    Put the API behind a CDN or cloud WAF and accept traffic only from it: provider IP ranges or a private connection, and an origin authentication header. Otherwise attackers keep using the published IP.

  5. 5. Prove the fix

    Describe the test that shows the change works.

    Expected result and reasoning

    In staging, run the slow-body probe with more connections than the limit and confirm the extras get 429 and the rest close within the configured bound. Confirm that normal uploads still succeed, and that a direct request to the origin is refused.

Your deliverable: An ordered change list for the nginx configuration and the network path, each with the measurement that will show it worked.

The incident data is supplied; the nginx, Envoy and Node.js behavior on this page was measured with local containers and a single client machine, not a distributed attack.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. CAPEC-469: HTTP DoS. CAPEC version 3.9.
  2. nginx: ngx_http_core_module
  3. nginx: ngx_http_limit_req_module
  4. F5 NGINX: HTTP/2 Rapid Reset attack impacting F5 NGINX products. 10 October 2023.
  5. nginx: CHANGES (1.25.3 HTTP/2 client detection, 1.29.8 max_headers)
  6. Google Cloud: mitigating a DDoS attack peaking above 398 million rps. 10 October 2023.
  7. Cloudflare: HTTP/2 Rapid Reset technical breakdown. 10 October 2023.
  8. CERT/CC VU#421644: HTTP/2 CONTINUATION frames. 3 April 2024.
  9. CERT/CC VU#767506: MadeYouReset. 13 August 2025.
  10. Calif: Codex discovered a hidden HTTP/2 bomb. 2 June 2026.
  11. Apache httpd 2.4 vulnerabilities (CVE-2026-49975)
  12. Envoy GHSA-22m2-hvr2-xqc8 (CVE-2026-47774). 3 June 2026.
  13. Envoy: configuring Envoy as an edge proxy
  14. Node.js 24 HTTP server. Node.js v24.21.0.
  15. AWS WAF: rate-based rule settings
  16. Google Cloud Armor: rate limiting overview
  17. Azure Front Door WAF rate limiting
  18. Google SRE book, chapter 21: handling overload

Reference information

Download this guide as Markdown