The most dangerous Nginx config I review is not the broken one; it is the one that works. It proxies to Laravel, gets an A on a TLS scanner, and quietly trusts everything: any X-Forwarded-For header a client sends, any certificate a public CA signed, any request rate the login endpoint receives, and any body framing the upstream is willing to guess at. Nothing in the error log hints at a problem until somebody else finds one.
I wrote this with help from AI tools and checked every directive and claim myself against the official Nginx documentation and changelog. I build PixelValut's developer and network tools, and I have run Nginx in front of PHP, Node.js and .NET services for nine years. This final part of the series is the hardening pass I apply to a reverse proxy before I call it zero-trust: identify the real client, limit what it can do, refuse ambiguous requests, and make internal hops prove who they are.
📚 Series Roadmap — Browser-Side Security & Zero-Trust Forensics
- Part 1: Deobfuscating Malicious npm & Browser Payloads: Base64, Hex, Unicode Escapes & Homoglyphs
- Part 2: Webhook Security in Production: HMAC-SHA256 Signatures, Timestamp Drift & Replay Protection
- Part 3: OAuth 2.1, OIDC ID Tokens & PKCE Forensics: Auditing Claims, Nonces & Audience Confusion in SPAs
- Part 4: Regular Expression Denial of Service (ReDoS) in JS & PHP: Spotting Catastrophic Backtracking
- Part 5: Subdomain Takeover & Dangling DNS Reconnaissance: Auditing CNAME, NS, TXT & TLS Certificates
- Part 6 (This Post): Zero-Trust Reverse Proxy Hardening in Nginx: Rate Limiting, Request Smuggling Guards & mTLS
How do you harden an Nginx reverse proxy for zero-trust?
Direct answer: Trust client IP headers only from your own load balancer ranges, then rate-limit per IP and per API key with limit_req, using stricter zones for login. Run a current Nginx release, keep request buffering on and timeouts short so ambiguous bodies are rejected, and require mTLS with a private CA for partner and service-to-service traffic.
Scope note: run the test commands below only against servers you own or are authorized to test.
Step 1: Know who the client really is
Every IP-based control depends on $remote_addr. Behind a load balancer or CDN, that is the balancer's address, so every user shares one rate-limit bucket. The usual fix, trusting X-Forwarded-For, is also the usual hole: if you trust it from anywhere, any client can claim any IP.
# http {} — trust forwarding headers ONLY from your own proxy tier
set_real_ip_from 10.20.0.0/16; # load balancer subnet
real_ip_header X-Forwarded-For;
real_ip_recursive on; # skip trusted hops, take the first untrusted one
At the edge, overwrite rather than append what you forward, so the upstream never sees a client-supplied value:
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
And refuse traffic for hostnames you do not serve, instead of letting the first server block answer it (the dangling-record problem from Part 5 is worse when your default server serves real content):
server {
listen 443 ssl default_server;
ssl_reject_handshake on; # no certificate needed, handshake is refused
}
Step 2: Rate limiting that matches how clients behave
limit_req implements a leaky bucket per key. A request whose key is an empty string is not counted, which lets you select a zone with a map:
# http {}
map $http_x_api_key $key_bucket { "" ""; default $http_x_api_key; }
map $http_x_api_key $anon_bucket { "" $binary_remote_addr; default ""; }
limit_req_zone $binary_remote_addr zone=ip_ceiling:10m rate=30r/s;
limit_req_zone $anon_bucket zone=anon:10m rate=5r/s;
limit_req_zone $key_bucket zone=per_key:20m rate=50r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_conn_zone $binary_remote_addr zone=conn_ip:10m;
limit_req_status 429;
limit_conn_status 429;
limit_req_log_level warn;
# server {}
location /api/ {
limit_req zone=ip_ceiling burst=60 nodelay;
limit_req zone=anon burst=10 nodelay;
limit_req zone=per_key burst=100 delay=50;
limit_conn conn_ip 20;
proxy_pass http://app_upstream;
}
location = /login {
limit_req zone=login burst=5 nodelay;
proxy_pass http://app_upstream;
}
Why three zones on /api/: an anonymous client gets a tight bucket, a keyed client gets a bigger one, and the IP ceiling always applies, because an attacker can send random X-Api-Key values to escape the anonymous zone. Nginx checks keys only for counting, not validity, so the ceiling is what stops that trick. Every listed zone is checked: the request is rejected if any zone is exceeded, and delayed by the longest delay any zone asks for.
From the docs: one megabyte of zone holds about 16,000 states for 64-byte entries such as $binary_remote_addr; when a zone fills, Nginx evicts old entries and, if it still cannot create a state, returns the error status. API keys are longer than IP addresses, so give that zone more room. Before enforcing, ship with limit_req_dry_run on; for a few days and read the log lines it produces; real traffic is burstier than anyone expects, especially webhook senders retrying in batches (see Part 2).
The Nginx config generator gives me a clean baseline server block to start from; the zones above are what I add on top.
Step 3: Request smuggling guards
Request smuggling happens when two HTTP processors disagree about where one request ends, usually because of conflicting Content-Length and Transfer-Encoding headers. The front proxy forwards one request; the backend sees two, and the second rides someone else's connection.
Modern Nginx closes many of the classic gaps itself. From the official changelog:
-
1.21.1: always returns an error if both
Content-LengthandTransfer-Encodingare present, and for spaces or control characters in the request line, header names orHost. -
1.21.2: rejects HTTP/1.0 requests with
Transfer-Encoding. - 1.29.4: a single LF as a line terminator in a chunked body is an error.
-
1.31.0: rejects HTTP/2 and HTTP/3 requests carrying
Connection,Transfer-Encoding,Upgradeand similar headers, which closes off a class of downgrade tricks. -
1.31.7 (October 2026): fixes CVE-2026-95101, where with HTTP/3 part of an unknown frame could be treated as request body, bypassing
client_max_body_sizeor injecting data into the proxied request.
Rule one: run a supported release and check nginx -v on every box, including the forgotten one. Then keep the helpful defaults and tighten the rest:
# http {}
server_tokens off;
ignore_invalid_headers on; # default; never turn off at the edge
underscores_in_headers off; # default
client_header_timeout 10s;
client_body_timeout 10s;
send_timeout 10s;
keepalive_timeout 15s;
client_max_body_size 2m; # raise per location for uploads
large_client_header_buffers 4 8k;
# location {} proxying to the app
proxy_request_buffering on; # default: read the whole body first
proxy_http_version 1.1; # default since 1.29.7; explicit for older builds
proxy_set_header Connection "";
proxy_request_buffering on matters: Nginx reads the full client body before talking to the upstream, so the backend receives a body with a definite length instead of whatever framing the client chose. Turn it off only for specific streaming endpoints, and then make sure the upstream parses HTTP strictly.
The remaining risk is usually a different hop. If a CDN or a second proxy sits in front of Nginx, the disagreement can happen between them. Test the chain you actually run:
printf 'POST /api/ping HTTP/1.1\r\nHost: api.example.com\r\nContent-Length: 4\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n' \
| openssl s_client -quiet -connect api.example.com:443 -servername api.example.com 2>/dev/null \
| head -n1
# Expected from Nginx directly: HTTP/1.1 400 Bad Request
If that returns anything other than a 400 through your CDN, find out which hop accepted it.
Step 4: mTLS for partners and internal hops
TLS proves the server to the client. Mutual TLS also makes the client prove itself with a certificate, which is the zero-trust answer for partner APIs, admin panels and service-to-service calls.
map $ssl_client_s_dn $partner_ok {
default 0;
"CN=acme-billing,O=Acme Ltd" 1;
}
server {
listen 8443 ssl; # dedicated listener for mTLS
http2 on;
server_name partners.example.com;
ssl_certificate /etc/nginx/pki/partners.example.com.fullchain.pem;
ssl_certificate_key /etc/nginx/pki/partners.example.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_client_certificate /etc/nginx/pki/partner-clients-ca.pem; # PRIVATE CA only
ssl_verify_client on;
ssl_verify_depth 2;
ssl_crl /etc/nginx/pki/partner-clients-ca.crl;
location / {
if ($partner_ok = 0) { return 403; }
proxy_set_header X-Client-Verify $ssl_client_verify;
proxy_set_header X-Client-DN $ssl_client_s_dn;
proxy_set_header X-Client-Fingerprint $ssl_client_fingerprint;
proxy_pass https://partners_api;
proxy_ssl_certificate /etc/nginx/pki/edge-to-api.pem;
proxy_ssl_certificate_key /etc/nginx/pki/edge-to-api.key;
proxy_ssl_trusted_certificate /etc/nginx/pki/internal-ca.pem;
proxy_ssl_verify on;
proxy_ssl_verify_depth 2;
proxy_ssl_server_name on;
proxy_ssl_name partners-api.internal;
}
}
The details that decide whether this is real security:
-
Trust a private CA, never a public bundle. If
ssl_client_certificatepoints at the system CA store, anyone with a publicly issued certificate passes verification. -
Verification is not authorization. A valid certificate proves it came from your CA; the
mapon$ssl_client_s_dn(or the fingerprint) decides which client may use this host. -
Overwrite identity headers.
proxy_set_headerreplaces anyX-Client-*value the client sent; the upstream must accept those headers only from Nginx's network. -
Verify the upstream too.
proxy_ssl_verifyis off by default, so without it the edge happily talks TLS to any server answering on that address. - Isolate mTLS virtual servers. CVE-2025-23419, fixed in 1.27.4, let a TLS 1.3 session from one virtual server be resumed on another sharing the listener, bypassing client certificate checks. A dedicated port or IP plus a current release removes that class of problem.
Test both paths:
curl -sS https://partners.example.com:8443/health # expect 400, no cert
curl -sS --cert acme.pem --key acme.key https://partners.example.com:8443/health # expect 200
For the public listener, the SSL Checker confirms the chain, SAN list and expiry, and the HTTP Status Checker shows which status code and redirects a hostname really returns.
Control comparison
| Control | Key directives | Threat addressed | Failure mode if misconfigured | How I verify |
|---|---|---|---|---|
| Real client IP |
set_real_ip_from, real_ip_header
|
Shared buckets, spoofed IPs | Trusting X-Forwarded-For from anyone |
Send a fake header from outside; log shows your real IP |
| Rate limiting |
limit_req_zone, limit_req, limit_conn
|
Credential stuffing, scraping, cost abuse | Keying on a client-controlled value with no IP ceiling | Parallel curl loop; count 429s |
| Smuggling guards | Current release, proxy_request_buffering, timeouts |
Desync, request hijacking | Old Nginx, buffering off, lax hop in front | CL+TE probe returns 400 end-to-end |
| mTLS to clients |
ssl_verify_client, ssl_client_certificate
|
Unauthenticated partner or admin access | Public CA bundle, no DN or fingerprint allowlist | Request without cert fails; wrong cert gets 403 |
| mTLS to upstream |
proxy_ssl_verify, proxy_ssl_certificate
|
Rogue internal service, MITM |
proxy_ssl_verify left off |
Point upstream at a self-signed server; request fails |
| Default server | ssl_reject_handshake |
Host header abuse, stray hostnames | Real app as implicit default |
openssl s_client -servername random.example.com fails |
Diagnostic checklist
- Is
set_real_ip_fromlimited to your own proxy ranges? - Does the edge overwrite
X-Forwarded-Forand identity headers? - Does a default server reject unknown hostnames?
- Do rate limits include an IP ceiling that client-supplied keys cannot escape?
- Is the login endpoint in its own strict zone?
- Did you run
limit_req_dry_runbefore enforcing? - Is every Nginx instance on a supported, patched release?
- Is request buffering on except for named streaming locations?
- Does a CL+TE probe return 400 through the full chain?
- Do mTLS hosts trust only a private CA, allowlist clients and sit on a dedicated listener?
- Is
proxy_ssl_verify onset for every TLS upstream?
FAQ
Should rate limiting live in Nginx or the application?
Both. Nginx is cheap and stops floods before PHP or Node.js wakes up. The application knows the user, plan and tenant, so business quotas belong there.
Does limit_req work across several Nginx servers?
No. Zones live in each instance's shared memory. Behind N proxies, a client effectively gets up to N times the configured rate unless the balancer keeps them sticky. For a global limit, use a shared store at the application or gateway layer.
Does HTTP/2 end request smuggling?
Not by itself. Downgrading HTTP/2 to HTTP/1.1 toward the backend has its own history of issues, which is why recent Nginx releases reject connection-specific headers in HTTP/2 and HTTP/3. Keep the upstream strict and the proxy current.
How do I rotate client certificates without downtime?
Put both the old and new CA certificates in the ssl_client_certificate file, issue new client certificates, reload Nginx, then remove the old CA after clients move. Revoke individual certificates through the CRL.
Is mTLS a replacement for API keys or OAuth?
No. It authenticates the machine connection. User-level authorization, scopes and audience checks from Part 3 still apply on top.
Series wrap-up
That closes Browser-Side Security & Zero-Trust Forensics. Over six parts we decoded obfuscated payloads, verified webhook signatures against replay, audited OIDC tokens and PKCE, found catastrophic regex backtracking, hunted dangling DNS records, and finished at the proxy that every request passes through. The pattern was the same each time: never trust input because of where it seems to come from, verify it explicitly, and test the configuration you actually run.
Follow & Connect
- Website: PixelValut Tools
- YouTube: PixelValut (@pixel-valut)
- TikTok: PixelValut (@pixelvalut007)
- GitHub: Muhammad Ali Mughal (@ali111887)
- LinkedIn: Muhammad Ali Mughal

Top comments (2)
One thing worth emphasizing is how easy it is to get rate limiting wrong behind a CDN or load balancer. If every visitor shares the same proxy IP, legitimate users can end up blocking each other. Testing the real client IP chain before enforcing limits is just as important as choosing the rate itself.
Great point, Mohsen, and it's the mistake I see most often. If nginx sits behind a CDN or load balancer,
$binary_remote_addris the proxy's IP, so one noisy client can throttle everyone behind it. The fix is the realip module:set_real_ip_fromfor only your CDN/LB ranges,real_ip_headerset to the header your provider uses (e.g.CF-Connecting-IPorX-Forwarded-For), andreal_ip_recursive on. Never trust that header from arbitrary sources, or clients can spoof their way past the limit. Before enforcing, I log$remote_addrnext to$realip_remote_addrfor a day and run withlimit_req_dry_run onto confirm the keys look like real clients. Thanks for adding this!