Nginx HTTP/2 hardening in practice: defend Rapid Reset
HTTP/2 with no limits gets resource-exhausted by Rapid Reset. This article covers Nginx HTTP/2 hardening: http2_max_concurrent_streams, keepalive_requests, limit_conn and limit_req as backstop, h2c off, plus h2load verification and CDN real-IP false-ban triage.
Applicable scenarios
HTTP/2 is on for most mainstream sites, but most configs care about "enable" and ignore "limit". Rapid Reset (CVE-2023-44487), disclosed in 2023, showed an attacker can open and immediately reset streams at high rate on a single TCP connection, bypass classic per-IP connection caps, and fill server memory and CPU at very low bandwidth. This is for Nginx sites that already have HTTP/2 on. Full hardening: concurrent stream limits, connection reuse caps, and timeout backstops. Fits ops still on older Nginx minor versions, or with no HTTP/2 rate limits at all.
Prerequisites
- Nginx version ≥ 1.25.3 (built-in Rapid Reset mitigation from this version), or the 1.24.0 / 1.22.1 patched branches
- TLS cert configured; site serves HTTPS on 443
- Permission to run `nginx -t` and `systemctl reload nginx`
- If a CDN sits in front, confirm the CDN-to-origin protocol version so rate-limit params are not mismatched
How it works
HTTP/2 multiplexing handles many request streams in parallel on one TCP connection. Default Nginx caps max concurrent streams per connection via http2_max_concurrent_streams, but counting of "established then immediately RST_STREAM" streams is not strict. Rapid Reset exploits that gap: the client sends HEADERS then immediately RST_STREAM. The server already allocated stream resources; the request is cancelled. One connection can create thousands of create–destroy cycles per second.
Effective hardening is three layers:
- Stream layer: cap concurrent streams per connection; use a lower concurrent ceiling early in the connection
- Connection layer: cap total requests a connection may handle (keepalive_requests), forcing the attacker to reconnect repeatedly. Repeated reconnects are caught by connection-layer rate limits and L4 protection
- Timeout layer: shorten idle and header-read timeouts so zombie connections release resources fast
Procedure
1. Confirm version and current HTTP/2 state
nginx -v
# Example output: nginx version: nginx/1.25.4
nginx -T 2>/dev/null | grep -i http2
If below 1.25.3 and you cannot upgrade, enable HTTP/2 on the fronting CDN or WAF and disable HTTP/2 on origin.
2. Configure HTTP/2 concurrent streams and connection-reuse limits
Declare globals in the http block, or override in a specific server / location:
# /etc/nginx/nginx.conf — http block
http {
# Max concurrent streams per connection: default 128; lower this against Rapid Reset
http2_max_concurrent_streams 64;
# Max requests a connection may handle: after this the server sends GOAWAY and forces reconnect
keepalive_requests 200;
# Connection idle timeout: shortening speeds up resource reclaim
keepalive_timeout 15s;
# Request header read timeout: blocks slow-header attacks
client_header_timeout 10s;
client_body_timeout 15s;
# Per-IP concurrent connection and request rate limits (connection-layer backstop)
limit_conn_zone $binary_remote_addr zone=perip_conn:10m;
limit_req_zone $binary_remote_addr zone=perip_req:10m rate=30r/s;
include /etc/nginx/conf.d/*.conf;
}
3. Enable and apply limits in the 443 server block
server {
listen 443 ssl;
server_name helixcyber.ai;
# Nginx 1.25.1+ uses a standalone directive to enable HTTP/2
http2 on;
ssl_certificate /etc/nginx/ssl/example.crt;
ssl_certificate_key /etc/nginx/ssl/example.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# Apply connection and request rate limits
limit_conn perip_conn 50;
limit_req zone=perip_req burst=60 nodelay;
# Cap concurrent HTTP/2 connections per IP (connection layer)
limit_conn_status 429;
location / {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
Parameter points: http2_max_concurrent_streams 64 means at most 64 parallel streams per connection; keepalive_requests 200 closes the connection after 200 requests — an attacker who wants to hold a high rate must keep opening new connections and then hits limit_conn; client_header_timeout 10s blocks slow-header variants.
4. Explicitly disable cleartext h2c
h2c (HTTP/2 over cleartext) has no TLS and is easy to abuse for request smuggling. Disable everywhere except internal trusted links. Check that port 80 is 301-only, no http2:
server {
listen 80;
server_name helixcyber.ai;
return 301 https://$host$request_uri;
}
5. Apply config
sudo nginx -t && sudo systemctl reload nginx
Configuration verification
Confirm HTTP/2 is on and rate limits fire:
# Check the protocol negotiated by the server
curl -sI --http2 https://helixcyber.ai | head -1
# Expected: HTTP/2 200
# Confirm via ALPN that the TLS layer negotiated h2
openssl s_client -connect helixcyber.ai:443 -alpn h2 -servername helixcyber.ai </dev/null 2>/dev/null | grep -i "ALPN protocol"
# Expected: ALPN protocol: h2
Verify concurrent-stream and request-rate limits trigger:
# Fire many requests quickly from one IP and watch for 429
ab -n 500 -c 80 -k https://helixcyber.ai/ 2>&1 | grep -E "Non-2xx|Complete requests"
# Expected: Non-2xx responses (429); not all 200 means rate limiting is in effect
Watch error log for trigger records:
tail -f /var/log/nginx/error.log | grep -iE "limiting connections|limiting requests"
FAQ
FAQ 1: Open-source Nginx does not show http2_max_concurrent_streams. How do you confirm it took effect?
After Nginx 1.25.1 this directive is managed by the http2 directive and the new HTTP/2 module. Some versions of `nginx -T` do not echo it. Confirm at runtime: use `h2load` (nghttp2 client) with a specified concurrent stream count and watch whether the connection refuses new streams after the threshold:
h2load -n 1000 -c 10 -m 128 https://helixcyber.ai/
# If the concurrent-stream cap is 64, max concurrent streams in the output should show 64
If the version truly lacks the directive, upgrade Nginx first, or cap concurrent streams uniformly at the CDN layer.
FAQ 2: After adding limit_conn, real users occasionally get 429. How do you balance?
429 false positives usually come from a too-tight threshold or CDN-to-origin. Handling principles:
- Distinguish real IP: with a CDN in front, `$binary_remote_addr` is the CDN node IP and you ban the whole CDN. Enable the real_ip module (set_real_ip_from + real_ip_header X-Forwarded-For) to restore client IP
- Distinguish resource type: static vs API reasonable concurrency differs a lot. Stricter limit_req on `/api/`, looser on static dirs
- Leave burst headroom: limit_req burst absorbs normal spikes; nodelay avoids queue delay. Start thresholds at daily P99 concurrency + 50%, then tighten from real alerts
After tuning, regression-test login, checkout, and other critical paths. Confirm no 429 before full rollout.
Summary
HTTP/2 security is not the protocol; it is "enabled with no limits". Rapid Reset-class attacks bypass per-IP connection caps, so hardening focus is concurrent stream ceiling (http2_max_concurrent_streams), per-connection request ceiling (keepalive_requests), and dual connection-count / rate limits. Land in order: confirm Nginx already contains Rapid Reset mitigation, set concurrent-stream and request thresholds to traffic, then verify with h2load and ab that limits actually fire, and check that real IP is restored in CDN scenarios.