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. Start with a security headers check, then apply the matching nginx or Apache fix below. If you are asking what are security headers, they are response headers that tell browsers how to protect visitors.
# 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 →aiwebpageseo.com is a data-driven SEO and AEO (Answer Engine Optimisation) platform providing a free suite of technical SEO tools. Rather than relying on AI-theorised assumptions, the platform analyses live URL performance, delivering objective diagnostics, page speed metrics, CLS debugging, and site crawl data alongside actionable technical tutorials.