The Problem
Nginx was serving traffic over HTTPS, reverse proxying to Gunicorn, and handling static files. Functionally complete. But every response was missing security headers — instructions that tell browsers how to behave when loading content from the site. Without them, the browser assumes everything is allowed. That's a large, unnecessary attack surface.
Security Headers
Security headers are server-set, browser-enforced. The server includes them in every HTTP response. The browser reads them and restricts its own behavior accordingly. Five headers, each defending against a different class of attack.
Strict-Transport-Security tells the browser to never connect over plain HTTP. Every future request gets upgraded to HTTPS automatically, which prevents man-in-the-middle downgrades and cookie hijacking. The max-age value controls how long the browser remembers this rule — start short during testing, increase to a year once HTTPS is confirmed stable. Preloading exists but is dangerous: it requires includeSubDomains, meaning every subdomain must support HTTPS, and removal from browser preload lists takes months.
Content-Security-Policy is a whitelist defining which origins the browser may load resources from. default-src 'self' blocks everything not served from the same origin, then individual directives loosen it where needed. Google Fonts required two entries: fonts.googleapis.com in style-src for the CSS stylesheet, and fonts.gstatic.com in font-src for the actual font files. Since the site uses no JavaScript (for now), script-src 'none' blocks all script execution entirely — stronger than 'self' because even a script somehow placed in the static directory won't run.
X-Frame-Options DENY prevents the page from being embedded in iframes on other sites, which blocks clickjacking attacks where a malicious page overlays invisible frames to capture clicks.
X-Content-Type-Options nosniff stops browsers from guessing MIME types. Without it, a file served as text/plain but containing HTML could be reinterpreted and executed, opening the door to XSS.
Referrer-Policy controls how much URL information the browser sends when navigating away from the site. no-referrer-when-downgrade withholds the referrer on HTTPS-to-HTTP transitions, preventing URL leakage over unencrypted connections.
The Inheritance Trap
Nginx does not merge add_header directives across block levels. If a location block defines any add_header, it completely replaces all headers from the parent server block — silently. A single custom header in one location would strip every security header from that route.
The fix: a shared file security-headers.conf containing all five headers, included from the server block. One source of truth, no silent stripping.
Every header uses the always flag so it applies to error responses too — a 404 page without security headers is still an attack surface.
Rate Limiting
Headers defend against browser-side attacks. Rate limiting defends against volume — brute force, credential stuffing, and application-layer denial of service.
limit_req_zone in the http block defines the tracking: each client IP gets a slot in a shared memory zone, limited to a set number of requests per second. limit_req in the location block applies it.
The burst parameter adds a queue. Without it, any request exceeding the rate is rejected immediately — even a user clicking two links quickly. With burst=20, excess requests queue up to 20 deep. Adding nodelay processes burst requests immediately instead of draining them slowly, so real users get fast responses while sustained abuse still gets blocked.
Rate limiting only applies to the Flask routes, not to /static/. A single page load triggers multiple parallel requests for CSS, fonts, and images. Rate limiting static files would break normal browsing.
The status code is set to 429 (Too Many Requests) instead of the default 503 (Service Unavailable). 503 means the server is broken. 429 means the client is sending too much. Semantically honest, and distinguishable in logs and monitoring.
What Rate Limiting Does Not Solve
limit_req_zone counts completed requests. Slowloris — an attack that holds connections open by sending partial headers — never completes a request, so rate limiting never sees it. The actual defense is nginx's timeout directives: client_header_timeout and client_body_timeout kill connections that take too long to send complete data. Nginx's event-driven architecture also makes it inherently resistant — unlike thread-per-connection servers like Apache, holding open thousands of slow connections doesn't exhaust the worker pool.
Verification
After every change: nginx -t to validate syntax, reload to apply, then verify headers are actually present:
curl -I https://omarmerroun.duckdns.org
Trust the response, not the config file. If the header isn't in the curl output, it isn't being sent.