Hardening Your WordPress Blog with HTTP Security Headers (Nginx)

Running a self-hosted WordPress blog on a VPS is great. You have full control over your stack, but that also means security hardening is entirely on you. A quick audit with Webbkoll revealed my site was missing several HTTP security headers that any production server should have. Here’s what I added, why, and exactly how.


The Audit

Webbkoll is a free privacy and security scanner that simulates a browser visit and checks what headers your server sends back. My results before the fix:

  • ✅ HTTPS active
  • ✅ Zero cookies
  • ✅ Zero third-party requests
  • ❌ No HSTS
  • ⚠️ No Referrer Policy (leaking referrers cross-origin)
  • ❌ Missing X-Content-Type-Options, X-Frame-Options, X-XSS-Protection

The good news: no tracking, no external fonts, no third-party scripts. The missing headers were all server-side config, fixable in five minutes.


What Each Header Does

Strict-Transport-Security (HSTS)
Forces browsers to always connect over HTTPS, even if the user types plain http://.

Referrer-Policy
Controls how much of your URL is sent to external sites when a visitor clicks a link.

X-Content-Type-Options: nosniff
Prevents browsers from guessing the content type of a file and acting on that guess.

X-Frame-Options: SAMEORIGIN
Blocks your site from being embedded in an <iframe> on other domains.

X-XSS-Protection: 0
Explicitly disables an old, deprecated browser XSS auditor that can introduce vulnerabilities if left active.

Permissions-Policy
Locks out browser APIs like camera, microphone and geolocation at the server level.


The Fix: Nginx Config

The full server block for /etc/nginx/sites-available/yourdomain:

server {
    listen 80;
    server_name yourdomain.com www.yourdomain.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name yourdomain.com www.yourdomain.com;

    ssl_certificate /etc/nginx/ssl/yourdomain.crt;
    ssl_certificate_key /etc/nginx/ssl/yourdomain.key;

    root /var/www/yourdomain;
    index index.php index.html;

    # Security Headers
    add_header Referrer-Policy           "strict-origin-when-cross-origin" always;
    add_header X-Content-Type-Options    "nosniff" always;
    add_header X-Frame-Options           "SAMEORIGIN" always;
    add_header X-XSS-Protection          "0" always;
    add_header Permissions-Policy        "geolocation=(), microphone=(), camera=()" always;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.5-fpm.sock;
    }

    location ~ /\.ht {
        deny all;
    }

    client_max_body_size 64M;
}

Test and reload:

nginx -t && systemctl reload nginx

A Note on HSTS

Don’t go straight to max-age=31536000 (one year). Once a browser sees that header, it will refuse plain HTTP connections to your domain for the entire duration, with no override and no bypass. If your SSL cert expires or you misconfigure something, visitors are locked out.

Start with 5 minutes, verify everything works, then increase:

# Start with 5 minutes
add_header Strict-Transport-Security "max-age=300; includeSubDomains" always;

# After verifying: bump to 1 year
sed -i 's/max-age=300/max-age=31536000/' /etc/nginx/sites-available/yourdomain
nginx -t && systemctl reload nginx

Verification

Run Webbkoll again after reloading Nginx. You can also check headers directly from the terminal:

curl -I https://yourdomain.com

Or target specific headers:

curl -sI https://yourdomain.com | grep -i 'strict-transport\|referrer\|x-content\|x-frame\|permissions'

Expected output:

referrer-policy: strict-origin-when-cross-origin
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
permissions-policy: geolocation=(), microphone=(), camera=()
strict-transport-security: max-age=31536000; includeSubDomains

Bonus: What Happens Without These Headers

If you want to understand the actual attack scenarios each header protects against, here is what an attacker could do without them.

HSTS: SSL stripping
When a visitor types your domain without https://, the browser first sends a plain HTTP request before being redirected. An attacker on the same network, say a coffee shop WiFi, can intercept that unencrypted first request before the redirect happens. They sit between the visitor and your server, keeping the connection to the visitor as plain HTTP while talking to your server over HTTPS. The visitor never sees a padlock and never knows they are on an insecure connection. Any data submitted at that point is readable in plain text. With HSTS set, the browser skips the HTTP request entirely and goes straight to HTTPS, so there is nothing to intercept.

Referrer-Policy: leaking URL structure
Without it, every time a visitor clicks an external link, the browser sends the full URL they came from to the destination site. If your URLs contain tracking parameters or internal structure, for example https://yourdomain.com/reviews/gpu/?ref=newsletter&user=123, that entire string gets handed to the external site. With strict-origin-when-cross-origin, the destination only sees https://yourdomain.com, nothing after the domain.

X-Content-Type-Options: malicious file uploads
If your site allows file uploads, an attacker could upload a file with an innocent extension like .jpg that actually contains JavaScript. Without this header, some browsers sniff the file contents, decide it looks like a script, and execute it. With nosniff, the browser strictly follows what the server declares the file to be, no matter what is inside it.

X-Frame-Options: clickjacking
An attacker loads your site in a transparent iframe on their own page, perfectly positioned over a button that says something like “Claim your prize.” The visitor thinks they are clicking the prize button but they are actually clicking something on your site underneath, for example a delete account confirmation or a settings change. With this header set, browsers refuse to load your site inside anyone else’s frame.

X-XSS-Protection: 0: auditor abuse
The old XSS auditor in Internet Explorer and early Chrome was meant to block malicious scripts, but attackers found ways to turn it against the site. By crafting a URL the auditor would flag as suspicious, they could force it to block legitimate scripts on your page, breaking functionality in ways they could then exploit. Modern browsers already removed the auditor. Setting this to 0 makes sure any remaining old browser that still has it turns it off.

Permissions-Policy: rogue script API access
If your site ever has an XSS vulnerability, or a plugin gets compromised and starts injecting code, that injected script could prompt your visitors for camera or microphone access. Because the prompt appears to come from your trusted domain, many users would click allow without thinking twice. With Permissions-Policy blocking these APIs at the server level, the browser denies those requests outright regardless of what any script on the page asks for.


A Note on HSTS Preload

You might notice Webbkoll flags preload as missing from your HSTS policy. Preload means submitting your domain to a hardcoded list that ships inside browsers like Chrome and Firefox. Without it, HSTS kicks in after the first visit. With preload, even a brand new browser that has never seen your site before goes straight to HTTPS.

The protection gain is marginal, covering only someone’s absolute first ever visit from a fresh browser. For most sites that is not a realistic threat.

The bigger issue is that preload is very hard to undo. Once you submit to the list at hstspreload.org, removal takes months and browsers update their built-in lists slowly. If you ever need to temporarily move to a different server, change your domain config, or let a cert lapse during a migration, you have no fallback. Visitors get locked out until browsers ship an update without your domain on it.

The current setup with max-age=31536000; includeSubDomains is already solid. Add preload after 6 to 12 months when the setup has proven itself stable and you are certain the domain and hosting are permanent.


Tools used: Webbkoll · Mozilla Observatory

Leave a Comment