Production Security And Server Hardening
Reverse Proxies And TLS Boundaries
PHP applications almost never face the internet directly. A reverse proxy — nginx, Apache, or Caddy — accepts every connection, terminates TLS, and forwards plain requests to php-fpm over a local socket. That boundary is where encryption ends, where trust decisions start, and where several classic misconfigurations live.
TLS Termination
The proxy holds the certificate and private key, speaks TLS to browsers, and speaks plaintext to the application over a channel that never leaves the machine (or leaves it only on a private network). Certificates come free and automated from Let's Encrypt via the ACME protocol: certbot or Caddy's built-in ACME client obtains them, proves domain control, and renews on a timer. Renewal is the part that fails in practice — certificates expire after 90 days by design, so verify the renewal timer actually fires (systemctl list-timers, and a certbot renew --dry-run) rather than discovering it in an outage.
Configuration policy is short: TLS 1.2 and 1.3 only, port 80 open solely to redirect to HTTPS, and HSTS once the redirect is proven stable:
add_header Strict-Transport-Security "max-age=31536000" always;
HSTS tells browsers to refuse plain HTTP for the domain for its max-age. It closes the redirect's small window, and it is a commitment — enable it after the HTTPS setup is stable, not as step one.
The Trust Boundary In Headers
Behind a proxy, the TCP peer the application sees is the proxy itself. The client's real address arrives, if at all, in headers the proxy sets: X-Forwarded-For, X-Forwarded-Proto. Two rules keep this honest. The proxy must overwrite these headers, not append blindly to whatever the client sent — otherwise any client can claim any IP. And the application must trust them only from the proxy's address, which is what framework "trusted proxies" configuration means. Rate limiting, audit logs, and "secure cookie only over HTTPS" logic all silently break when either side gets this wrong.
The same boundary carries responsibility inward: the proxy should pass a request identifier and receive honest status codes back, because a proxy that turns application errors into generic pages — or worse, into 200 — blinds monitoring, as the API lessons in track 10 discuss.
The Proxy As Policy Point
Because every request crosses it, the proxy is the natural place for concerns that should not burden PHP: static file serving, response compression, connection limits, request body size caps (client_max_body_size aligned with upload_max_filesize), and the rate limiting lesson's controls. Keep the proxy config in version control with the application: it is part of the system's behavior, and "worked locally, broke behind nginx" bugs are usually a config nobody could diff.
What To Check
Before moving on, make sure you can:
- trace a request from browser TLS through the proxy to php-fpm's socket
- explain ACME issuance and why renewal, not issuance, is the failure point
- state the two rules that make
X-Forwarded-*headers trustworthy - explain what HSTS commits you to and when to enable it
- name the request-wide policies that belong at the proxy layer
What You Should Be Able To Do
After this lesson, you should be able to stand up a proxy that terminates TLS with auto-renewing certificates, configure the header trust boundary correctly on both sides, and decide which cross-cutting controls live at the proxy rather than in PHP.
Practice
Practice: Design The TLS Boundary
Show solution
Port 80 serves exactly one thing: a 301 to HTTPS (plus the ACME challenge path). After a week of stable HTTPS, add HSTS with a modest max-age, raising it once confident.
Headers: nginx sets X-Forwarded-For from the connection address and X-Forwarded-Proto explicitly, overwriting client-supplied values. Laravel's trusted-proxy config names only the proxy's address, so request()->ip() and secure URL generation are correct and unspoofable.
At the proxy: client_max_body_size matched to PHP's upload limits, gzip for text responses, static assets served directly, connection and request-rate limits per the rate-limiting lesson. TLS protocols pinned to 1.2/1.3.
Everything above lives in a versioned config file deployed with the app, so the boundary is diffable and reviewable like code.
Practice: Diagnose A Header Trust Failure
After moving behind a new load balancer, an application's rate limiter starts banning the load balancer's IP, audit logs show one address for all users — and a penetration test then shows attackers can evade the rate limiter entirely by sending a forged X-Forwarded-For.
Task
Explain both symptoms and the paired misconfiguration behind them.
Show solution
The two symptoms are the two halves of the header contract, each broken on a different side.
Symptom one — every user appears as the load balancer's IP — means the application is not consuming X-Forwarded-For: its trusted-proxy configuration was never updated for the new balancer, so it correctly refuses the header and falls back to the TCP peer, which is the balancer. Rate limiting a single shared address then throttles everyone at once.
Symptom two — forged headers work — means that once trusted-proxy config is "fixed" naively (trust the header from anywhere), or the balancer appends rather than overwrites, the client controls its own identity. An attacker rotates fabricated addresses in X-Forwarded-For and the rate limiter counts each as a new user.
The paired fix: the balancer must set the header from the connection it accepted, discarding client-supplied values (or the application must take only the entry added by the trusted hop); and the application must trust the header only when the request arrives from the balancer's known addresses. Verify with two tests: a normal request logs the real client IP, and a request bearing a forged header from outside logs the forger's own address.
Practice: Define TLS Boundary Checks
Show solution
- External certificate monitoring: expiry beyond 14 days, chain valid, and the served certificate matches the expected domain set. This catches dead renewal timers before browsers do.
certbot renew --dry-run(or the ACME client's equivalent) runs green on schedule, separately from the real renewal.- An external TLS scan asserts policy: 1.2/1.3 only, no legacy ciphers, HSTS header present with the intended max-age, and port 80 answers only with the redirect and ACME path.
- Header integrity probe: a scripted request with a forged
X-Forwarded-Forfrom outside must be logged under the sender's real address; a normal request through the proxy must log the true client address.
Quarterly, human:
- Diff the live proxy config against the version-controlled copy; any drift is either committed or reverted, never left ambiguous.
- Re-read the trusted-proxy list on the application side against current infrastructure — balancers change, and stale trust is the quiet failure.