Originally published on kuryzhev.cloud
Nginx TLS hardening usually gets attention at the worst possible moment: a pentest report flags missing security headers, or a compliance auditor asks why SSLv3 is still negotiable on a box nobody remembers deploying. The fix people reach for first is a header. The actual fix is deciding, once, who owns TLS and header policy — nginx itself, or a layer in front of it.
When this choice matters
The decision point shows up in a few recognizable situations: preparing for an SSL Labs–style scan ahead of a compliance audit, standing up an nginx reverse proxy in front of a growing list of backend services, or closing out a pentest finding about missing Content-Security-Policy or Strict-Transport-Security headers.
None of these are one-time fixes. Cipher recommendations and header guidance change periodically rather than on any fixed schedule — the Mozilla SSL Configuration Generator and NIST's TLS guidance both get revised as weak algorithms are deprecated and new attack classes get published. A config that scored an "A" two years ago can quietly degrade as TLS 1.0/1.1 deprecation guidance tightens or as CAs change what they support. Nobody re-scans a working proxy until something breaks it.
This comparison is about where hardening policy lives — hand-written in nginx directives, or delegated to a shared generator/edge layer — not a line-by-line cipher suite recommendation. Cipher lists specifically should come from a maintained generator, not a static list copied from an old blog post, since TLS 1.3 ignores legacy ssl_ciphers syntax entirely and stale strings give a false sense of hardening.
Option A: Manual, hand-tuned nginx directives
This is the default approach for most teams: TLS settings and security headers written directly into server {} blocks, reviewed in pull requests, and deployed the same way as any other nginx change.
Pros: full control per virtual host, no external dependency or third-party trust boundary, works fine air-gapped or on-prem, and the entire policy is reviewable in git history — useful when an auditor asks "who approved this change and when."
Cons: drift is the real cost. Across ten or twenty server blocks, it's easy to forget the always flag on add_header, or to let one vhost fall behind when TLS guidance changes. Upkeep becomes a recurring chore rather than a one-time task.
The gotcha that catches people repeatedly: add_header directives do not merge across nested blocks. If a location block defines its own add_header, it silently discards every header set in the parent server block — nginx does not combine the lists. A team can set five hardened headers at the server level, add one location /api block with a single add_header for CORS, and lose the other four without any warning. The documented behavior is in the nginx headers module reference; verify with curl -I against the actual endpoint, not just the config file.
Option B: Delegated/automated hardening
The alternative is centralizing policy at a CDN, WAF, or API gateway layer, or generating a shared nginx snippet from a single source of truth and including it everywhere via infrastructure-as-code.
Pros: one policy, applied consistently across many services. Updating a cipher suite or rotating a header value happens in one place instead of N places. It can also offload TLS termination cost and CPU overhead from the application tier.
Cons: less per-service granularity — a service with unusual requirements has to fight the shared policy or get an exception. There's also real risk of duplicate or conflicting headers between the edge and nginx itself, and vendor-specific quirks in how headers get injected, rewritten, or stripped in transit.
Two gotchas matter here. First, when both the edge layer and nginx set the same header, the two mechanisms don't fail the same way. Duplicate or conflicting X-Frame-Options values are generally treated as invalid by the browser and the header gets dropped outright — you lose the protection silently, with no error anywhere. Content-Security-Policy behaves differently: duplicate CSP headers are combined, and the browser enforces the most restrictive intersection of the two policies, which can produce a working-but-unintended result that's hard to trace back to its source. Either way, this only shows up when you inspect the live response with curl or browser dev tools, not when you read the config. Second, "hardened at the edge" quietly becomes an assumption that the internal hop is fine too. If the CDN terminates TLS with a strong profile but the connection from reverse proxy to backend runs on an outdated internal certificate or a legacy cipher set, the public-facing scan looks great while the actual attack surface between proxy and app server stays wide open.
Decision matrix
Use these axes to decide instead of defaulting to whichever approach the team already knows:
- Control granularity — manual wins if individual services genuinely need different policies (e.g., one legacy internal API can't yet drop an old cipher).
- Maintenance overhead — delegated wins once you're updating the same directive in more than a handful of places for the same reason.
- Audit reproducibility — IaC-applied shared config (Terraform or Ansible pushing the identical snippet everywhere) produces evidence auditors can trust faster than hand-edited per-host files, which is directly relevant for PCI-DSS or SOC 2 evidence collection.
- Latency/cost impact — edge termination can reduce load on app-tier nginx, but adds a network hop and a vendor dependency to reason about during incidents.
- Team size and service count — this is a heuristic, not a derived number. A single service or a small team can run manual directives safely if paired with automated scanning. Once a shared reverse proxy sits in front of several backend services — five is a reasonable trigger point for many teams, but the real signal is how often you're copy-pasting the same directive across vhosts, not the count itself — centralize via a shared include file or an edge policy instead of duplicating configuration by hand.
Neither option removes the need for testing. Both fail the same way if nobody scans the live endpoint after a change.
Evidence-based recommendation
Treat hardening as code regardless of which option you pick. A hand-written directive set and a generated edge policy are both fine as long as they live in version control, get applied through CI/CD, and aren't edited directly on a running host. The example below is Option A, but centralized into a shared snippet to avoid the per-vhost drift that makes manual configs risky at scale.
# security-headers.conf — shared snippet included by every server{} block
# This file must NOT contain server{} or listen directives — add_header
# is invalid in nginx's main context and the config test will refuse to load it.
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# X-Frame-Options is superseded by the CSP frame-ancestors directive below.
# Keep it only for legacy browsers that don't support frame-ancestors —
# it is not the primary clickjacking control anymore.
add_header X-Frame-Options "DENY" always;
# Ramp HSTS gradually: start short and without includeSubDomains. A 5-minute
# max-age combined with includeSubDomains fails PCI-DSS/SOC 2 scan checks and
# SSL Labs' HSTS grading outright. Raise max-age over weeks, and add
# includeSubDomains only once every subdomain is confirmed HTTPS-only —
# HSTS preload submission is effectively irreversible for months.
add_header Strict-Transport-Security "max-age=300" always;
# CSP: keep in Report-Only in staging until it stops breaking things,
# then enforce on a known date. CSP mitigates XSS but doesn't close the gap
# on its own — treat it as a defense-in-depth layer alongside output
# encoding and input sanitization, not a replacement for either.
add_header Content-Security-Policy "default-src 'self'; object-src 'none'; frame-ancestors 'none'" always;
# backend.conf — one upstream and one server block per hardened virtual host
upstream backend_upstream {
server 10.0.1.10:8443;
server 10.0.1.11:8443;
}
server {
listen 443 ssl;
server_name example.internal;
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
# Modern-only protocol set — TLS 1.0/1.1 should not be offered in a 2026 baseline
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off; # irrelevant for TLS1.3; harmless for 1.2 fallback
# Pull the current string from https://ssl-config.mozilla.org/ — nginx's
# built-in default (HIGH:!aNULL:!MD5) still allows non-PFS and CBC-SHA1
# suites on TLS 1.2 and will fail a strict scan without this line.
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off; # avoid stale ticket key reuse across restarts/instances
# OCSP stapling still helps on certs from CAs that publish OCSP responses,
# but Let's Encrypt shut down its OCSP responder in 2025, and CA/Browser
# Forum policy is moving away from OCSP fleet-wide. Confirm your issuer
# still supports it before treating this as a required control.
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/nginx/ssl/chain.pem;
resolver 1.1.1.1 8.8.8.8 valid=300s ipv6=off;
resolver_timeout 5s;
include /etc/nginx/snippets/security-headers.conf;
location / {
proxy_pass https://backend_upstream; # hardened hop, not plain http
# proxy_ssl_verify alone has no CA store to validate against — pair
# it with proxy_ssl_trusted_certificate and proxy_ssl_server_name on,
# or the handshake to the backend fails.
proxy_ssl_verify on;
proxy_ssl_trusted_certificate /etc/nginx/ssl/internal-ca.pem;
proxy_ssl_server_name on;
}
}
Whichever architecture is chosen, validate every deploy against staging with an automated scanner instead of eyeballing the config file. Config review catches typos; it does not catch a location block silently discarding parent headers, or a stapling responder that's unreachable from the current network.
# Validation loop — run in CI against staging before merging nginx changes
# 1. Confirm headers are actually served, not stripped by a location override
curl -sI https://staging.example.com | grep -Ei \
'strict-transport-security|content-security-policy|x-frame-options|x-content-type-options'
# 2. Grade the TLS handshake and cipher/protocol support
testssl.sh --protocols -E https://staging.example.com
# Expected outcome checklist:
# [ ] TLSv1.0 / TLSv1.1 -> NOT offered
# [ ] TLSv1.3 -> offered, preferred
# [ ] HSTS -> present, max-age matches current rollout stage
# [ ] CSP -> enforced (not Report-Only) past the rollout deadline
# [ ] OCSP stapling -> conditional check: "stapling supported" only applies
# if the issuing CA still runs an OCSP responder;
# a Let's Encrypt cert issued post-2025 will show
# "not supported" and that's expected, not a failure
# [ ] Duplicate headers -> none (check edge layer output separately if one is in front)
Three concrete recommendations follow from this: first, run testssl.sh or sslyze against staging on every nginx change, treating the TLS and header config as testable code rather than something reviewed by eye. Second, apply the low-risk headers — X-Content-Type-Options: nosniff and Referrer-Policy: strict-origin-when-cross-origin — everywhere regardless of which architecture you pick; the risk is low but not zero, since a stricter Referrer-Policy value can break referrer-dependent analytics or OAuth-adjacent redirect flows that expect the full referrer, so check those paths before flipping the value fleet-wide. Third, and most often skipped: verify the internal hop from proxy to backend is hardened too, because an edge layer with a perfect SSL Labs grade says nothing about the connection behind it.
For teams running this across a Kubernetes ingress fleet rather than standalone nginx hosts, the same centralization argument applies at the ingress-controller layer — see the related coverage on rolling out pod security standards on kuryzhev.cloud for how policy-as-code decisions play out at that layer.
Nginx TLS hardening is not a header you paste once and forget. It's an architectural decision about where policy lives, how it's tested, and who's responsible when the next TLS deprecation notice lands. Pick manual directives for small, tightly controlled fleets paired with CI-driven scanning; pick a delegated edge policy once per-host drift becomes the bigger risk than losing per-service granularity. Either way, the scanner output — not the config file — is the source of truth.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.