Dense reference for sysadmins. Assumes nginx and Apache fluency, root-shell access and the ability to read RFCs. Covers the configuration patterns, the inheritance traps, and the things that look right but fail in production. For step-by-step UI walkthroughs see the fix index. For platform-specific instructions see the Plesk or WordPress guides. For the why behind each check see the main guide.
# http or server block — applies to all locations unless overridden add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), interest-cohort=()" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" always;
add_header directives at parent contexts (http, server) are discarded as soon as any child context (location, if) declares its own add_header. The always parameter only forces the header on non-2xx responses — it does NOT make headers cascade through child blocks. If you add a single add_header inside any location, you must re-declare ALL parent headers there. Use include /etc/nginx/security-headers.conf; in each location to avoid drift.
# In httpd.conf, virtualhost block, or .htaccess
<IfModule mod_headers.c>
Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=()"
Header always set Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; frame-ancestors 'self'"
</IfModule>
Use Header always set (forces) vs Header set (skipped on error responses). The always condition matters for 4xx/5xx — without it, error pages leak through unprotected.
| Stack | Directive | Location |
|---|---|---|
| nginx | server_tokens off; | http block |
| nginx (full strip) | more_clear_headers Server; | http block (requires ngx_headers_more) |
| Apache | ServerTokens ProdServerSignature Off | httpd.conf |
| PHP | expose_php = Off | php.ini, all SAPI variants |
| Express (Node) | app.disable('x-powered-by') | after express() init |
| Plesk-managed nginx | xPoweredByHeader = off under [webserver] | /usr/local/psa/admin/conf/panel.ini |
ngx_headers_more can hide the version (server_tokens off) but cannot remove the Server header entirely. The Server: nginx string itself remains. Most security audits — including ours — accept "no version" as a pass. Removing the entire header requires the module or a reverse proxy upstream.ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers off; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; ssl_session_timeout 1d; ssl_session_cache shared:SSL:50m; ssl_session_tickets off; ssl_stapling on; ssl_stapling_verify on; resolver 1.1.1.1 1.0.0.1 valid=300s; resolver_timeout 5s;
SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1 SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305 SSLHonorCipherOrder off SSLSessionTickets off SSLUseStapling on SSLStaplingCache "shmcb:logs/ssl_stapling(32768)"
TLSv1.3 only breaks ~5% of legitimate clients in 2026 (older Android, corporate proxies, IoT). Use TLS 1.2 + 1.3 unless you have audited every client. Our security audit's "TLS Protocol Modern" check passes for either configuration.
# nginx — cannot set on cookies it didn't create; use proxy_cookie_flags (nginx 1.19.3+) proxy_cookie_flags ~ secure httponly samesite=lax;
# Apache — same constraint; rewrite Set-Cookie via mod_headers Header edit Set-Cookie ^(.*)$ "$1; Secure; HttpOnly; SameSite=Lax"
Lax unless you have audited every cross-site flow. Strict is for high-value session cookies only.
Use CSP to enforce HTTPS upgrades on legacy content rather than chasing every hardcoded http:// URL in templates and database content:
Content-Security-Policy: upgrade-insecure-requests; ...
Or for stricter blocking rather than upgrading:
Content-Security-Policy: block-all-mixed-content; ...
upgrade-insecure-requests rewrites http:// to https:// transparently. block-all-mixed-content refuses to load mixed resources entirely. Both are CSP directives; the audit checks for absence of mixed content in the HTML, not for these directives — but they are the right defence in depth.
# nginx — generate nonce per request set $cspNonce $request_id; add_header Content-Security-Policy "script-src 'nonce-$cspNonce' 'strict-dynamic' https:; style-src 'self' 'nonce-$cspNonce'; ..." always; sub_filter 'CSP_NONCE_PLACEHOLDER' $cspNonce; sub_filter_once off;
Application templates emit <script nonce="CSP_NONCE_PLACEHOLDER">. nginx's sub_filter substitutes the actual nonce at response time. strict-dynamic lets nonce-tagged scripts dynamically load others without listing every host.
Content-Security-Policy-Report-Only: ...; report-uri /csp-report; report-to default Reporting-Endpoints: default="/csp-report"
Deploy enforcement-grade CSP as report-only first. Collect violations for 1-2 weeks, identify legitimate sources you missed, then switch to enforcing.
v=spf1 include:_spf.google.com include:mailgun.org -all
Use -all (hard fail) in production, ~all (soft fail) only during initial setup. Avoid include: chains beyond depth 10 — SPF lookup limit is 10 DNS queries total per RFC 7208.
v=DMARC1; p=reject; sp=reject; rua=mailto:dmarc-agg@yourdomain.com; ruf=mailto:dmarc-fail@yourdomain.com; fo=1; aspf=s; adkim=s; pct=100
Start with p=none, collect aggregate reports, fix legitimate failures, then escalate to p=quarantine then p=reject. aspf=s; adkim=s requires strict alignment — most production setups use relaxed (r).
# Per email provider (Google, Microsoft, SES, Mailgun): provider gives you # selector + key pair. Publish at <selector>._domainkey.<domain>: v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCB...
Rotate DKIM keys annually. Modern selectors use 2048-bit RSA minimum; 1024-bit is deprecated and flagged by stricter receivers.
Contact: mailto:security@yourdomain.com Contact: https://yourdomain.com/security Expires: 2027-12-31T23:59:59.000Z Encryption: https://yourdomain.com/pgp-key.txt Acknowledgments: https://yourdomain.com/security/hall-of-fame Preferred-Languages: en Canonical: https://yourdomain.com/.well-known/security.txt Policy: https://yourdomain.com/security/policy
Required: Contact, Expires. Expires MUST be ≤ 1 year per RFC. Serve as Content-Type: text/plain; charset=utf-8. nginx default_type for /.well-known/ may be JSON in some templates — override with a location block.
Audit flags a CVE: assess by CVSS score and exploit availability, not by CVE-ID alone.
The audit doesn't check SRI but it's table-stakes for any third-party scripts you do allow:
<script src="https://cdn.example.com/lib.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous"></script>
# Full header dump curl -sI https://yourdomain.com/ | sort # TLS protocol/cipher negotiated openssl s_client -connect yourdomain.com:443 -tls1_3 < /dev/null 2>&1 | grep -E "Protocol|Cipher" # Cert expiry echo | openssl s_client -servername yourdomain.com -connect yourdomain.com:443 2>/dev/null | openssl x509 -noout -dates # SPF/DMARC/DKIM dig +short TXT yourdomain.com dig +short TXT _dmarc.yourdomain.com dig +short TXT default._domainkey.yourdomain.com # CSP enforcement test (will block, not just warn) curl -sI https://yourdomain.com/ | grep -i content-security-policy # Confirm security.txt curl -sI https://yourdomain.com/.well-known/security.txt | grep -i content-type
max-age=300 first, escalate after a week, only submit preload after a month of stability$_SERVER['HTTPS'] sees the proxy connection, not the client connection. Set SetEnvIf X-Forwarded-Proto "https" HTTPS=on in Apache or check the header in your applicationssl_stapling_verify on in nginx prevents stapled responses being spoofed. Don't omit itRun a scan to see which findings actually apply to your domain.
Run free security audit →