TLS & security

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.

· 18 min read · How we verify this

Key points

  • The edge must overwrite inbound X-Forwarded-*, X-Real-IP and Forwarded, not append to them; appending makes every downstream client-IP decision attacker-controlled.
  • nginx defaults proxy_ssl_verify to off and proxy_ssl_server_name to off, so upstream HTTPS is unauthenticated and SNI-less unless you set both.
  • Once set_real_ip_from matches, $remote_addr is 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 at level 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.

CheckWhy it mattersHow to verify
The outermost proxy overwrites inbound X-Forwarded-For, not appendsAn appended value means the leftmost entry is attacker-supplied. Any code taking the first element is trusting the clientSend 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 edgeA forged X-Forwarded-Proto: https defeats HTTPS-redirect logic and can make a session cookie be issued over plaintextSend each header with a bogus value; confirm the origin sees the edge's value
Any other trust-carrying header the edge injects is stripped inboundHeaders such as an authentication assertion or an internal tenant identifier are trusted precisely because the edge sets themEnumerate every proxy_set_header at the edge; each name must also be neutralised on input
The trusted-proxy list contains exact CIDRs, not broad rangesset_real_ip_from 10.0.0.0/8 trusts every host in the VPC, including a compromised workloadCompare the list against the actual load balancer subnets; check against the client IP resolver
Origins accept traffic only from the CDN or load balancerOtherwise the trust boundary is bypassable by connecting to the origin directlyResolve the origin address and request it directly from outside; expect a connection failure or 403
real_ip_recursive matches the actual chain depthWith 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'sSend a multi-entry X-Forwarded-For from a trusted hop and check the derived address
Header names with underscores are handled deliberatelynginx drops headers containing underscores by default (underscores_in_headers off), so X_Forwarded_For never arrives, which can mask a bug rather than fix itSend an underscore variant and observe whether it reaches the origin

2. Request handling#

CheckWhy it mattersHow to verify
Requests carrying both Content-Length and Transfer-Encoding are rejected and the connection closedRFC 9112 section 6.1 requires the close. Rejecting without closing leaves the attacker's unread octets in the buffer for the next requestprintf the raw request into openssl s_client; expect 400 and an immediate FIN
Obfuscated Transfer-Encoding forms are rejectedTransfer-Encoding : chunked and chunked, identity are the standard desync primitivesSend each variant; expect 400. See HTTP request smuggling and proxy desync
Duplicate Content-Length with differing values is rejectedTwo hops that resolve the conflict differently produce a desyncSend 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 valuesUnvalidated downgrade turns an inert HTTP/2 field value into a complete second HTTP/1.1 requestSend 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 explicitlynginx 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 forwardsSend an oversized header; expect 431 or 400 from the edge, not 502 from the origin
client_max_body_size matches what the application acceptsnginx defaults to 1m, so large uploads fail at the edge with 413 before any application logic runsUpload at the documented maximum; confirm no 413
Methods are allowlistedTRACE, TRACK and unexpected verbs reach application frameworks that handle them inconsistentlySend TRACE /; expect 405
Host is allowlisted and an explicit default_server returns 444An unmatched Host otherwise lands on whichever server block is first, which is how a catch-all becomes an open proxyRequest with Host: example.invalid; expect the connection to close with no response
URI normalisation happens once, at the edge, before routingTwo hops that decode %2f differently route differently, which bypasses path-based rulesRequest an encoded traversal sequence; compare edge and origin access logs for the path each recorded

3. TLS configuration#

CheckWhy it mattersHow to verify
ssl_protocols is set explicitly to TLSv1.2 TLSv1.3The compiled default has changed across nginx versions; relying on it means your policy changes when you upgradeopenssl s_client -tls1_1 -connect host:443 should fail
Cipher policy is explicit and TLS 1.3 suites are left aloneTLS 1.3 suites are not configured through ssl_ciphers; a long legacy cipher string gives false confidence about 1.3 connectionsEnumerate the offered suites with a scanner; check both protocol versions separately
HSTS is sent with an appropriate max-ageWithout it, the first plaintext navigation is interceptable. RFC 6797 defines the field; preload requires at least one year plus includeSubDomains and preloadcurl -sI https://host/ and read the Strict-Transport-Security field
HSTS is not sent over plaintext or from hosts you do not fully controlincludeSubDomains applies to every subdomain, including ones a different team runs on plain HTTPCheck every subdomain before enabling includeSubDomains
OCSP stapling is on and actually staplingssl_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 supportopenssl s_client -status -connect host:443 and look for a non-empty OCSP response
Certificate expiry is alerted on, not merely monitoredThe single most common total outage at the proxy layer, and it fails at exactly 00:00 UTC on a date nobody diarisedAn 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 orderA missing intermediate works in browsers with AIA fetching and fails in Java, Go and curlopenssl s_client -showcerts from a host with no cached intermediates
Private keys are mode 0600 and owned by root, not readable by the worker usernginx reads keys as root at master startup; worker-readable keys expose them to any worker-process compromisestat the key files and compare with the user directive
Session tickets are rotatedA static ticket key undermines forward secrecy for the lifetime of the keyConfirm a rotation mechanism exists; a key file that has not changed since deployment is a finding

4. Upstream#

CheckWhy it mattersHow to verify
No variable in proxy_pass without a strict allowlistA variable upstream is an SSRF primitive; it also disables URI normalisationGrep for proxy_pass containing $; every hit needs an exact-match map. See SSRF and the proxy layer
proxy_ssl_verify on where upstream is HTTPSnginx defaults this to off, so the upstream TLS connection authenticates nothing and any DNS or routing influence redirects it silentlyPoint 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 setAlso off by default, so nginx sends no SNI upstream; multi-tenant upstreams then return the wrong certificate or the wrong sitePacket capture or upstream logs showing the SNI value
mTLS to backends where the network is sharedCertificate-based mutual authentication means a compromised neighbouring workload cannot impersonate the proxyPresent no client certificate and confirm the backend refuses
A timeout is set on every legnginx 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 missingCompare each hop's timeouts with the timeout ladder checker
Timeouts decrease outwardIf 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 chainInduce a slow upstream and observe which hop gives up first
Upstream connection reuse is a deliberate choicenginx 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-outCheck ss -tn on the origin for long-lived connections from the proxy

5. Rate limiting and abuse#

CheckWhy it mattersHow to verify
limit_req on authentication and other expensive endpointsCredential stuffing and enumeration are request-rate problems, not payload problemsDrive traffic above the rate and confirm rejection
limit_req_status 429 set explicitlynginx returns 503 by default, which clients and CDNs interpret as a server fault and retry, amplifying the load you were limitingcurl -i past the limit and read the status
The limit key is the derived client address, not $remote_addr blindlyBehind a CDN, $remote_addr may be the CDN edge, so one key covers thousands of usersCheck that set_real_ip_from runs before the limit_req_zone key is evaluated
limit_conn per address and a global connection capConcurrency exhaustion is a separate failure from request rate; slow clients consume slots without consuming rateOpen many concurrent connections from one source
client_header_timeout and client_body_timeout are shortBoth default to 60s in nginx. Slowloris works by sending a header byte every 59 seconds; shortening these to 10s to 15s is the direct mitigationOpen a connection, send one header byte, and time the close
keepalive_timeout boundedDefaults to 75s in nginx; idle connections occupy worker slotsss -tn state established counts during quiet periods
Request body buffering behaviour is understoodWith buffering on, the proxy absorbs slow uploads and shields the origin; with it off, a slow client holds an origin workerUpload 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.

CheckWhy it mattersHow to verify
Envoy admin interface is not on 0.0.0.0It exposes the full configuration, secrets metadata, and /quitquitquit, which terminates the process. It is not a read-only dashboardRead 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=trueThat flag serves the dashboard and API on port 8080 with no authentication; Traefik's own documentation says not to use it in productioncurl http://host:8080/api/rawdata from outside; expect a refusal
HAProxy stats socket is a UNIX socket, not TCP, and level admin is deliberateAt level admin the socket can disable servers, change weights and drain backendsInspect the stats socket line; confirm mode and ownership
nginx stub_status and Apache /server-status are restrictedThey disclose connection counts, and ExtendedStatus on Apache discloses the URLs currently being servedRequest the path from an external address; expect 403 or 444
Metrics endpoints require authentication or network restrictionMetric labels routinely leak internal hostnames, upstream names and route structurecurl the metrics path from outside
Debug and trace endpoints are off in productionGo's net/http/pprof and equivalents expose memory contentsRequest the known debug paths
Health check endpoints do not leak versions or upstream detailA health endpoint returning a build hash and dependency list is a reconnaissance aidRead the response body, not just the status
Default and sample virtual hosts are removedDistribution packages ship a default site that serves a welcome page and confirms the software and versionRequest the origin address directly with an unknown Host

7. Logging and detection#

CheckWhy it mattersHow to verify
The socket peer is logged alongside the derived client addressWithout both you cannot tell a spoofing attempt from a legitimate forwarded request. Use $realip_remote_addr and $remote_addrGrep a log line for two distinct address fields
$host is loggedA Host outside your expected set indicates open-proxy probing or a smuggled request that carried its own HostAlert on $host values not in the configured set
The request target form is visibleAbsolute-form targets (GET http://... HTTP/1.1) are reserved for proxies and have no legitimate reason to reach an originGrep the access log for :// inside the request field
Upstream response time and status are logged separately from client-facing statusA 502 served to the client tells you nothing about which upstream failed or how long it tookConfirm $upstream_addr, $upstream_status and $upstream_response_time are in the log format
TLS version and cipher are loggedNeeded to measure the impact before deprecating a protocol versionConfirm $ssl_protocol and $ssl_cipher are present
Alerts on 4xx and 5xx rate shifts, not absolute thresholdsAbsolute thresholds are wrong at every traffic level; a 401 or 429 ratio shift is the credential-stuffing signalConfirm alerts are defined on rate of change or on ratio
A 405 for a near-miss method is alerted onGPOST or POSTGET at the origin is the signature of a TE.CL desync and has no benign causeConfirm a rule exists
Logs leave the proxy hostAn attacker with proxy access edits local logs firstConfirm shipping and check for a gap-detection alert

8. Process#

CheckWhy it mattersHow to verify
All proxy configuration is in version controlReviewability and attribution; most trust-boundary regressions are a one-line edit made under time pressureThe running config matches the repository, verified by a diff job rather than by assertion
Configuration is syntax-checked before reloadnginx -t, haproxy -c -f haproxy.cfg, envoy --mode validate -c envoy.yaml, caddy validateThe check is a pipeline gate, not a habit
Changes roll out to a subset firstTrust-boundary and framing changes break specific clients rather than all of them, so a canary catches what a syntax check cannotConfirm a staged mechanism exists
Reload rather than restart, and connection draining is configuredA restart drops in-flight connections, which turns a routine change into an availability eventReload under load and watch for connection resets
Modules and their CVEs are trackedThe proxy binary's version is not the whole attack surface; dynamic modules, Lua scripts and WAF rulesets have their own advisoriesAn inventory exists and is checked against vendor advisories
Security response has an owner and a target timeThe smuggling and SSRF classes are patched reactively; an unowned patching process is the reason old CVEs persistNamed owner, documented target
The checklist is re-run after network topology changesBind addresses and origin lock-down assumptions are invalidated by new subnets, new load balancers and published container portsRe-run items 1 and 6 after any topology change

Worked example: an edge server block implementing sections 1 to 4#

nginx
# 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.

  1. nginx ngx_http_realip_module
  2. nginx ngx_http_core_module directives and defaults
  3. nginx ngx_http_proxy_module, proxy_ssl_verify
  4. nginx ngx_http_limit_req_module
  5. Envoy admin interface
  6. Traefik API and dashboard
  7. HAProxy Management Guide, stats socket
  8. RFC 6797 HTTP Strict Transport Security
  9. RFC 7239 Forwarded HTTP Extension
  10. 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.

More in tls and proxy security#