Reverse proxy security checklist
An auditable reverse proxy checklist covering trust boundary, framing, TLS, upstream, rate limits, exposure, logging and process, and how to verify each.
Key points
- The edge must overwrite inbound
X-Forwarded-*,X-Real-IPandForwarded, not append to them; appending makes every downstream client-IP decision attacker-controlled. - nginx defaults
proxy_ssl_verifytooffandproxy_ssl_server_nametooff, so upstream HTTPS is unauthenticated and SNI-less unless you set both. - Once
set_real_ip_frommatches,$remote_addris replaced; log$realip_remote_addr(nginx 1.9.7 and later) or you lose the only untrusted-but-true fact you had. - Admin surfaces bound to
0.0.0.0(Envoy's admin interface, Traefik's insecure API, HAProxy's stats socket atlevel admin) are full control planes, not dashboards.
A reverse proxy is the only component that sees every request before your application does, which makes it the best place to enforce security properties and the worst place to get them wrong. This checklist is organised by category; each item states what to check, why it matters, and how to verify it from outside the box rather than by reading the configuration file. Configuration describes intent, and the gap between intent and behaviour is where the findings are.
Work through it in order. The categories are sequenced by blast radius: a trust boundary error invalidates every authorisation decision downstream, while a missing rate limit degrades availability. If you only have an afternoon, do the first three.
1. Trust boundary and forwarded headers#
The edge proxy is the only hop that knows the true client address, because it is the only hop with an unforgeable TCP peer. Every hop after it works from an assertion. The entire discipline is deciding where assertions start being trustworthy and enforcing that boundary exactly once.
| Check | Why it matters | How to verify |
|---|---|---|
The outermost proxy overwrites inbound X-Forwarded-For, not appends | An appended value means the leftmost entry is attacker-supplied. Any code taking the first element is trusting the client | Send X-Forwarded-For: 1.2.3.4 from outside and confirm the origin sees only your real address |
Inbound X-Real-IP, X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port and Forwarded are stripped or overwritten at the edge | A forged X-Forwarded-Proto: https defeats HTTPS-redirect logic and can make a session cookie be issued over plaintext | Send each header with a bogus value; confirm the origin sees the edge's value |
| Any other trust-carrying header the edge injects is stripped inbound | Headers such as an authentication assertion or an internal tenant identifier are trusted precisely because the edge sets them | Enumerate every proxy_set_header at the edge; each name must also be neutralised on input |
| The trusted-proxy list contains exact CIDRs, not broad ranges | set_real_ip_from 10.0.0.0/8 trusts every host in the VPC, including a compromised workload | Compare the list against the actual load balancer subnets; check against the client IP resolver |
| Origins accept traffic only from the CDN or load balancer | Otherwise the trust boundary is bypassable by connecting to the origin directly | Resolve the origin address and request it directly from outside; expect a connection failure or 403 |
real_ip_recursive matches the actual chain depth | With it off, only the last entry is examined; with it on, trusted entries are skipped right to left. The wrong setting yields either the proxy's address or an attacker's | Send a multi-entry X-Forwarded-For from a trusted hop and check the derived address |
| Header names with underscores are handled deliberately | nginx drops headers containing underscores by default (underscores_in_headers off), so X_Forwarded_For never arrives, which can mask a bug rather than fix it | Send an underscore variant and observe whether it reaches the origin |
2. Request handling#
| Check | Why it matters | How to verify |
|---|---|---|
Requests carrying both Content-Length and Transfer-Encoding are rejected and the connection closed | RFC 9112 section 6.1 requires the close. Rejecting without closing leaves the attacker's unread octets in the buffer for the next request | printf the raw request into openssl s_client; expect 400 and an immediate FIN |
Obfuscated Transfer-Encoding forms are rejected | Transfer-Encoding : chunked and chunked, identity are the standard desync primitives | Send each variant; expect 400. See HTTP request smuggling and proxy desync |
Duplicate Content-Length with differing values is rejected | Two hops that resolve the conflict differently produce a desync | Send two conflicting fields; expect 400, never a silent collapse |
HTTP/2 to HTTP/1.1 downgrade validates content-length and rejects CR/LF in field values | Unvalidated downgrade turns an inert HTTP/2 field value into a complete second HTTP/1.1 request | Send an HTTP/2 request with a field value containing \r\n; expect a stream reset, never a downgraded message |
| Header and URI size limits are set explicitly | nginx defaults to client_header_buffer_size 1k and large_client_header_buffers 4 8k; an origin with smaller limits than the edge will return 502 for requests the edge happily forwards | Send an oversized header; expect 431 or 400 from the edge, not 502 from the origin |
client_max_body_size matches what the application accepts | nginx defaults to 1m, so large uploads fail at the edge with 413 before any application logic runs | Upload at the documented maximum; confirm no 413 |
| Methods are allowlisted | TRACE, TRACK and unexpected verbs reach application frameworks that handle them inconsistently | Send TRACE /; expect 405 |
Host is allowlisted and an explicit default_server returns 444 | An unmatched Host otherwise lands on whichever server block is first, which is how a catch-all becomes an open proxy | Request with Host: example.invalid; expect the connection to close with no response |
| URI normalisation happens once, at the edge, before routing | Two hops that decode %2f differently route differently, which bypasses path-based rules | Request an encoded traversal sequence; compare edge and origin access logs for the path each recorded |
3. TLS configuration#
| Check | Why it matters | How to verify |
|---|---|---|
ssl_protocols is set explicitly to TLSv1.2 TLSv1.3 | The compiled default has changed across nginx versions; relying on it means your policy changes when you upgrade | openssl s_client -tls1_1 -connect host:443 should fail |
| Cipher policy is explicit and TLS 1.3 suites are left alone | TLS 1.3 suites are not configured through ssl_ciphers; a long legacy cipher string gives false confidence about 1.3 connections | Enumerate the offered suites with a scanner; check both protocol versions separately |
HSTS is sent with an appropriate max-age | Without it, the first plaintext navigation is interceptable. RFC 6797 defines the field; preload requires at least one year plus includeSubDomains and preload | curl -sI https://host/ and read the Strict-Transport-Security field |
| HSTS is not sent over plaintext or from hosts you do not fully control | includeSubDomains applies to every subdomain, including ones a different team runs on plain HTTP | Check every subdomain before enabling includeSubDomains |
| OCSP stapling is on and actually stapling | ssl_stapling on is silent when the certificate carries no OCSP responder URL, which is now the case for some CAs including Let's Encrypt after it ended OCSP support | openssl s_client -status -connect host:443 and look for a non-empty OCSP response |
| Certificate expiry is alerted on, not merely monitored | The single most common total outage at the proxy layer, and it fails at exactly 00:00 UTC on a date nobody diarised | An alert at 21 days and 7 days remaining, checked from outside the network against the served chain, not the file on disk |
| The full chain is served, in order | A missing intermediate works in browsers with AIA fetching and fails in Java, Go and curl | openssl s_client -showcerts from a host with no cached intermediates |
| Private keys are mode 0600 and owned by root, not readable by the worker user | nginx reads keys as root at master startup; worker-readable keys expose them to any worker-process compromise | stat the key files and compare with the user directive |
| Session tickets are rotated | A static ticket key undermines forward secrecy for the lifetime of the key | Confirm a rotation mechanism exists; a key file that has not changed since deployment is a finding |
4. Upstream#
| Check | Why it matters | How to verify |
|---|---|---|
No variable in proxy_pass without a strict allowlist | A variable upstream is an SSRF primitive; it also disables URI normalisation | Grep for proxy_pass containing $; every hit needs an exact-match map. See SSRF and the proxy layer |
proxy_ssl_verify on where upstream is HTTPS | nginx defaults this to off, so the upstream TLS connection authenticates nothing and any DNS or routing influence redirects it silently | Point the upstream at a host with a self-signed certificate; expect a 502, not a success |
proxy_ssl_server_name on and proxy_ssl_name set | Also off by default, so nginx sends no SNI upstream; multi-tenant upstreams then return the wrong certificate or the wrong site | Packet capture or upstream logs showing the SNI value |
| mTLS to backends where the network is shared | Certificate-based mutual authentication means a compromised neighbouring workload cannot impersonate the proxy | Present no client certificate and confirm the backend refuses |
| A timeout is set on every leg | nginx defaults proxy_connect_timeout, proxy_send_timeout and proxy_read_timeout to 60s each; HAProxy sets no defaults and warns at startup when they are missing | Compare each hop's timeouts with the timeout ladder checker |
| Timeouts decrease outward | If the edge times out before the origin, the origin keeps working on an abandoned request and the connection slot is held twice. See timeout budgets across a proxy chain | Induce a slow upstream and observe which hop gives up first |
| Upstream connection reuse is a deliberate choice | nginx before 1.29.7 does not reuse upstream connections unless keepalive is declared, plus proxy_http_version 1.1 and proxy_set_header Connection ""; from 1.29.7 reuse is the default (keepalive 32 local), so the choice flips from opt-in to opt-out | Check ss -tn on the origin for long-lived connections from the proxy |
5. Rate limiting and abuse#
| Check | Why it matters | How to verify |
|---|---|---|
limit_req on authentication and other expensive endpoints | Credential stuffing and enumeration are request-rate problems, not payload problems | Drive traffic above the rate and confirm rejection |
limit_req_status 429 set explicitly | nginx returns 503 by default, which clients and CDNs interpret as a server fault and retry, amplifying the load you were limiting | curl -i past the limit and read the status |
The limit key is the derived client address, not $remote_addr blindly | Behind a CDN, $remote_addr may be the CDN edge, so one key covers thousands of users | Check that set_real_ip_from runs before the limit_req_zone key is evaluated |
limit_conn per address and a global connection cap | Concurrency exhaustion is a separate failure from request rate; slow clients consume slots without consuming rate | Open many concurrent connections from one source |
client_header_timeout and client_body_timeout are short | Both default to 60s in nginx. Slowloris works by sending a header byte every 59 seconds; shortening these to 10s to 15s is the direct mitigation | Open a connection, send one header byte, and time the close |
keepalive_timeout bounded | Defaults to 75s in nginx; idle connections occupy worker slots | ss -tn state established counts during quiet periods |
| Request body buffering behaviour is understood | With buffering on, the proxy absorbs slow uploads and shields the origin; with it off, a slow client holds an origin worker | Upload slowly and watch where the connection is held |
6. Exposure#
Every proxy ships an operational surface that is a control plane rather than a status page. These are among the most reliably productive findings in an internal audit.
| Check | Why it matters | How to verify |
|---|---|---|
Envoy admin interface is not on 0.0.0.0 | It exposes the full configuration, secrets metadata, and /quitquitquit, which terminates the process. It is not a read-only dashboard | Read the admin.address block; bind to 127.0.0.1 or a pipe: UNIX socket, then curl the port from another host and expect a refusal |
Traefik dashboard and API are not exposed with --api.insecure=true | That flag serves the dashboard and API on port 8080 with no authentication; Traefik's own documentation says not to use it in production | curl http://host:8080/api/rawdata from outside; expect a refusal |
HAProxy stats socket is a UNIX socket, not TCP, and level admin is deliberate | At level admin the socket can disable servers, change weights and drain backends | Inspect the stats socket line; confirm mode and ownership |
nginx stub_status and Apache /server-status are restricted | They disclose connection counts, and ExtendedStatus on Apache discloses the URLs currently being served | Request the path from an external address; expect 403 or 444 |
| Metrics endpoints require authentication or network restriction | Metric labels routinely leak internal hostnames, upstream names and route structure | curl the metrics path from outside |
| Debug and trace endpoints are off in production | Go's net/http/pprof and equivalents expose memory contents | Request the known debug paths |
| Health check endpoints do not leak versions or upstream detail | A health endpoint returning a build hash and dependency list is a reconnaissance aid | Read the response body, not just the status |
| Default and sample virtual hosts are removed | Distribution packages ship a default site that serves a welcome page and confirms the software and version | Request the origin address directly with an unknown Host |
7. Logging and detection#
| Check | Why it matters | How to verify |
|---|---|---|
| The socket peer is logged alongside the derived client address | Without both you cannot tell a spoofing attempt from a legitimate forwarded request. Use $realip_remote_addr and $remote_addr | Grep a log line for two distinct address fields |
$host is logged | A Host outside your expected set indicates open-proxy probing or a smuggled request that carried its own Host | Alert on $host values not in the configured set |
| The request target form is visible | Absolute-form targets (GET http://... HTTP/1.1) are reserved for proxies and have no legitimate reason to reach an origin | Grep the access log for :// inside the request field |
| Upstream response time and status are logged separately from client-facing status | A 502 served to the client tells you nothing about which upstream failed or how long it took | Confirm $upstream_addr, $upstream_status and $upstream_response_time are in the log format |
| TLS version and cipher are logged | Needed to measure the impact before deprecating a protocol version | Confirm $ssl_protocol and $ssl_cipher are present |
| Alerts on 4xx and 5xx rate shifts, not absolute thresholds | Absolute thresholds are wrong at every traffic level; a 401 or 429 ratio shift is the credential-stuffing signal | Confirm alerts are defined on rate of change or on ratio |
A 405 for a near-miss method is alerted on | GPOST or POSTGET at the origin is the signature of a TE.CL desync and has no benign cause | Confirm a rule exists |
| Logs leave the proxy host | An attacker with proxy access edits local logs first | Confirm shipping and check for a gap-detection alert |
8. Process#
| Check | Why it matters | How to verify |
|---|---|---|
| All proxy configuration is in version control | Reviewability and attribution; most trust-boundary regressions are a one-line edit made under time pressure | The running config matches the repository, verified by a diff job rather than by assertion |
| Configuration is syntax-checked before reload | nginx -t, haproxy -c -f haproxy.cfg, envoy --mode validate -c envoy.yaml, caddy validate | The check is a pipeline gate, not a habit |
| Changes roll out to a subset first | Trust-boundary and framing changes break specific clients rather than all of them, so a canary catches what a syntax check cannot | Confirm a staged mechanism exists |
| Reload rather than restart, and connection draining is configured | A restart drops in-flight connections, which turns a routine change into an availability event | Reload under load and watch for connection resets |
| Modules and their CVEs are tracked | The proxy binary's version is not the whole attack surface; dynamic modules, Lua scripts and WAF rulesets have their own advisories | An inventory exists and is checked against vendor advisories |
| Security response has an owner and a target time | The smuggling and SSRF classes are patched reactively; an unowned patching process is the reason old CVEs persist | Named owner, documented target |
| The checklist is re-run after network topology changes | Bind addresses and origin lock-down assumptions are invalidated by new subnets, new load balancers and published container ports | Re-run items 1 and 6 after any topology change |
Worked example: an edge server block implementing sections 1 to 4#
# Trusted edge devices only. Exact CIDRs, never a whole VPC.
set_real_ip_from 203.0.113.0/24;
set_real_ip_from 198.51.100.0/24;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
log_format edge '$realip_remote_addr $remote_addr $host "$request" '
'$status $body_bytes_sent $request_time '
'$upstream_addr $upstream_status $upstream_response_time '
'$ssl_protocol/$ssl_cipher';
limit_req_zone $binary_remote_addr zone=auth:10m rate=5r/s;
limit_conn_zone $binary_remote_addr zone=perip:10m;
server {
listen 443 ssl default_server;
server_name _;
ssl_reject_handshake on; # nginx 1.19.4 and later
return 444;
}
server {
listen 443 ssl;
http2 on; # nginx 1.25.1 and later
server_name app.example.com;
access_log /var/log/nginx/edge.log edge;
ssl_protocols TLSv1.2 TLSv1.3;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
client_header_timeout 12s; # slowloris; default is 60s
client_body_timeout 12s; # default is 60s
client_max_body_size 20m; # default is 1m
limit_conn perip 40;
limit_req_status 429; # default is 503
if ($request_method !~ ^(GET|HEAD|POST|PUT|PATCH|DELETE)$) { return 405; }
location / {
# Overwrite the trust boundary. Never append at the edge.
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Forwarded "";
proxy_set_header X-Auth-Assertion "";
proxy_pass https://app_backend;
proxy_ssl_verify on; # default is off
proxy_ssl_server_name on; # default is off
proxy_ssl_name app.internal;
proxy_ssl_trusted_certificate /etc/nginx/internal-ca.pem;
proxy_connect_timeout 3s;
proxy_send_timeout 20s;
proxy_read_timeout 20s;
}
location /login { limit_req zone=auth burst=10 nodelay; proxy_pass https://app_backend; }
}Two details do the heavy lifting. proxy_set_header X-Forwarded-For $remote_addr; overwrites rather than appending, which is what $proxy_add_x_forwarded_for would do; appending at the outermost hop is the single most common trust-boundary defect, and the X-Forwarded-For page covers why. Setting a header to the empty string is nginx's way of not passing it upstream at all, which is how inbound Forwarded and any internal assertion header are neutralised.
Failure modes this checklist prevents#
Every request appears to come from the load balancer. set_real_ip_from does not cover the actual load balancer subnet, or real_ip_header names a header the load balancer does not set. Rate limits keyed on the address then apply globally rather than per client, and one abusive user throttles everyone.
Users behind a corporate NAT are throttled as one client. limit_req keyed on the derived address is working exactly as configured. Key on a session or account identifier for authenticated endpoints and reserve address-keyed limits for unauthenticated ones.
502 Bad Gateway after tightening upstream TLS. proxy_ssl_verify on is now doing its job and the upstream certificate does not validate against proxy_ssl_trusted_certificate, or proxy_ssl_name does not match its subject. This is a finding, not a regression: the connection was previously unauthenticated.
400 Bad Request from the edge after enabling strict framing. A legacy client relied on lenient parsing. Identify it by source address from the logs and fix the client; relaxing the edge reopens the desync path.
A working change breaks on reload but not on nginx -t. Syntax checking validates the file, not the runtime: missing certificate files, unresolvable upstream names and permission errors surface only on reload. Stage the rollout.
Frequently asked questions#
Should the reverse proxy append to or overwrite X-Forwarded-For?#
The outermost proxy, the one terminating the client connection, must overwrite it with the socket peer address. Internal hops behind that boundary may append. Appending at the edge means the leftmost entry is whatever the client sent, so any code reading the first element is reading attacker-controlled data.
What is the most commonly missed reverse proxy security setting?#
Upstream certificate verification. nginx defaults proxy_ssl_verify to off and proxy_ssl_server_name to off, so an https:// upstream is authenticated against nothing and sends no SNI. Teams see https in proxy_pass, assume the connection is verified, and get an encrypted channel to an unauthenticated peer.
How do I stop slowloris attacks at the proxy?#
Shorten client_header_timeout and client_body_timeout from their 60 second defaults to roughly 10 to 15 seconds, cap concurrent connections per source with limit_conn, and keep request body buffering enabled so the proxy rather than the origin absorbs slow senders. The attack depends on a connection being held cheaply for a long time, so bounding the hold time removes it.
Is exposing the Envoy admin interface actually dangerous?#
Yes. It is a control plane, not a status page: it exposes the full effective configuration, allows runtime modification, and includes /quitquitquit, which terminates the process. Bind it to loopback or a UNIX domain socket, never to 0.0.0.0, and reach it through a bastion or a sidecar rather than the network.
What should a reverse proxy access log contain for security purposes?#
At minimum the socket peer address and the derived client address as separate fields, the Host, the full request line, the status, the upstream address, upstream status and upstream response time, plus the negotiated TLS version and cipher. The two address fields matter most: with only one, a spoofing report cannot be investigated at all.
How often should this checklist be re-run?#
At least after every network topology change, since bind addresses and origin lock-down assumptions are invalidated by new subnets, new load balancers and newly published container ports. Sections 1 and 6, the trust boundary and the exposed admin surfaces, are the ones that silently regress; the rest are stable once set.
Does a WAF replace any of these items?#
No, and it adds an item. A WAF is another HTTP parser in the chain, so it introduces another opportunity for framing divergence, as described in HTTP request smuggling and proxy desync. It inspects the request as it parsed it, which is not necessarily what the origin will parse. Treat it as an additional hop subject to sections 2 and 6 of this checklist.
Primary sources#
Every normative claim on this page is checked against the specification or the vendor documentation listed here. Where behaviour is version dependent, the version is named in the text.
- nginx ngx_http_realip_module
- nginx ngx_http_core_module directives and defaults
- nginx ngx_http_proxy_module, proxy_ssl_verify
- nginx ngx_http_limit_req_module
- Envoy admin interface
- Traefik API and dashboard
- HAProxy Management Guide, stats socket
- RFC 6797 HTTP Strict Transport Security
- RFC 7239 Forwarded HTTP Extension
- RFC 9112 HTTP/1.1, section 6 Message Body
Found something wrong, or behaviour that differs on your version? Report it with the version number and a primary source. Anything substantive is fixed in the page and logged on the corrections page. See editorial standards for how pages are researched, sourced and reviewed.