/ Security Audit Fixes / Expert Reference

Expert Reference: Fixing Security Audit Findings

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 security headers — nginx

# 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;
Header inheritance trap nginx 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.

HTTP security headers — Apache

# 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.

Server fingerprint suppression

StackDirectiveLocation
nginxserver_tokens off;http block
nginx (full strip)more_clear_headers Server;http block (requires ngx_headers_more)
ApacheServerTokens Prod
ServerSignature Off
httpd.conf
PHPexpose_php = Offphp.ini, all SAPI variants
Express (Node)app.disable('x-powered-by')after express() init
Plesk-managed nginxxPoweredByHeader = off under [webserver]/usr/local/psa/admin/conf/panel.ini
nginx without 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.

TLS configuration

nginx (modern profile, Mozilla intermediate)

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;

Apache

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)"
TLS 1.3-only is a foot-gun Limiting to 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.

Cookie security flags

# 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"
SameSite=Strict breaks OAuth and inbound deep links SameSite=Strict prevents cookies on cross-site requests — including the redirect back from OAuth providers, payment processors and email links to authenticated pages. Default to Lax unless you have audited every cross-site flow. Strict is for high-value session cookies only.

Mixed content

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.

CSP — production patterns

Nonce-based CSP (preferred over unsafe-inline)

# 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.

Report-only deployment

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.

Mail security DNS

SPF — apex TXT

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.

DMARC — _dmarc.<domain> TXT

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).

DKIM — generate, publish, sign

# 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.

security.txt — RFC 9116

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.

CVE response — operational pattern

Audit flags a CVE: assess by CVSS score and exploit availability, not by CVE-ID alone.

  1. Cross-reference the CVE against CISA's Known Exploited Vulnerabilities (KEV) catalogue — if listed, treat as critical regardless of CVSS
  2. Check exploit-DB and GitHub for published PoC — exploit availability changes risk profile
  3. Patch to the fixed version; if no patch available, mitigate via WAF rule or feature disable
  4. Strip the version fingerprint so the same CVE doesn't show up cosmetically again next scan
  5. Subscribe to vendor security advisories (nginx-announce, php-internals-announce, apache-announce) for proactive notice

Subresource integrity (bonus)

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>

Verification commands

# 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

Production gotchas, briefly

🛡 Run the audit

Run a scan to see which findings actually apply to your domain.

Run free security audit →
Related Guides: All Fix Guides  ·  Beginner Guide  ·  Fix in Plesk  ·  Full Reference
💬 Got a problem?