WordPress Security Headers, Explained Properly
By Stu 8 min read
Security headers are one of those bits of WordPress hardening that everyone knows they should do and almost nobody explains clearly. The advice usually amounts to "add these headers" followed by a block of configuration, with no discussion of what any of them do, which ones actually matter, and which ones will quietly break your site if you get them wrong.
This post walks through the HTTP security headers worth setting on a WordPress site. What each one does, why it matters, and the configuration I'd recommend. There's also a short list of headers that get mentioned a lot and shouldn't.
Why headers, and what they do
An HTTP security header is a line the server adds to its response, telling the browser how to behave with the page it's about to render. The header travels with every response, and the browser is obliged to respect it.
The reason they matter is that a lot of web attacks work by getting the browser to do something on the attacker's behalf. Loading a hostile script. Framing your site inside a fake login page. Following a redirect that leaks a session token. Security headers close off those routes at the browser level, before an attacker can exploit them.
They're one of the highest value-to-effort items on any hardening list. Set them once in your web server config, and every response benefits from them permanently.
HSTS (Strict-Transport-Security)
What it does tells the browser to only ever talk to your site over HTTPS, even if the user types http:// or follows an old link. Once the browser has seen this header, it will refuse to connect over HTTP for the duration specified.
Why it matters an SSL certificate alone isn't enough. If HTTP still answers on port 80, an attacker on the same network can intercept the first request and downgrade the connection before HTTPS ever kicks in. HSTS closes that gap by making the browser refuse the downgrade.
The header
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
max-age=31536000 is one year in seconds. includeSubDomains extends the protection to every subdomain. preload opts you into the browser preload list, which means new visitors get the protection before they've ever visited your site.
The catch once you set HSTS, you can't easily undo it. Every browser that's seen the header will remember for the max-age period. Set this only when you're certain every subdomain works on HTTPS, and start with a shorter max-age (say 86400, one day) if you want to test first.
Content-Security-Policy (CSP)
What it does lists exactly which sources the browser is allowed to load scripts, styles, images, and other content from. Anything not on the list is blocked, no matter what the page's HTML says.
Why it matters cross-site scripting is one of the most common web vulnerabilities. If an attacker manages to inject a <script> tag into your page, CSP is the last line of defence that can stop it running. Without CSP, the browser will happily execute any script that ends up in the HTML.
Why WordPress makes it hard WordPress and its ecosystem load scripts and styles from a huge range of sources. Core WordPress. Themes. Plugins. Gutenberg blocks. Third party analytics. Embedded fonts. CDN-hosted libraries. A CSP tight enough to be useful and loose enough to not break the site is genuinely difficult to write.
A workable starting point
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self'; frame-ancestors 'none';
That policy allows the site's own resources, tolerates inline scripts and styles (which WordPress uses heavily), permits Google Fonts, allows images from anywhere over HTTPS, and forbids the site being framed by anyone. It's not the strictest possible CSP. It's a starting point that works with a typical WordPress install without breaking things.
The honest truth getting a genuinely strict CSP working on WordPress is a project, not a one-line config change. If you're serious about it, use Content-Security-Policy-Report-Only first to log violations without enforcing, then tighten from there over weeks.
X-Frame-Options
What it does stops other sites from loading your site inside an iframe.
Why it matters clickjacking attacks work by loading your site inside a transparent frame, positioned over a lure page. The user thinks they're clicking a button on the visible page, but they're actually clicking something inside the framed WordPress admin. This is how attackers have historically got admins to unwittingly approve plugin installs or user changes.
The header
X-Frame-Options: SAMEORIGIN
SAMEORIGIN lets your own site frame itself (for previews and such) but blocks everyone else. Some guides recommend DENY for extra safety. Either works for most WordPress sites.
Note that frame-ancestors 'none' in the CSP above does the same job, and modern browsers prefer it. Setting both is fine and gives you coverage on older browsers.
X-Content-Type-Options
What it does stops the browser from guessing the type of a file. If the server says a file is a PNG, the browser treats it as a PNG, even if the contents look like JavaScript.
Why it matters without this header, browsers do "MIME sniffing", where they inspect file contents and decide the type themselves. Attackers have historically abused this to upload a file that the server labels as an image, but that the browser executes as JavaScript.
The header
X-Content-Type-Options: nosniff
There's one value. There are no trade-offs. Set it.
Referrer-Policy
What it does controls how much information about the previous page is sent when a user clicks a link from your site to another site.
Why it matters by default, browsers send the full URL of the referring page in the Referer header on outbound requests. That means URL parameters, tokens, and query strings on your site leak to whatever site the user clicks through to. In a WordPress context, that can include password reset URLs, admin session tokens in URL parameters, or private content links.
The header
Referrer-Policy: strict-origin-when-cross-origin
That value sends the origin (scheme + domain) but not the path or query string when going to a different site, and sends the full URL only within your own site. It's the sensible default and matches what modern browsers are moving to anyway.
Permissions-Policy
What it does controls which browser features (camera, microphone, geolocation, USB, etc.) the page and any framed content are allowed to use.
Why it matters most WordPress sites have no legitimate reason to access the camera or microphone. If your site is compromised and hostile code is injected, this header stops the code from silently activating those features against the visitor.
A sensible baseline
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()
Each () means "no origins allowed". If your site legitimately needs one of these (a WooCommerce site using the Payment Request API, for example) leave that entry out.
Setting them on WordPress
The right place to set these headers is at the web server layer, not in a WordPress plugin. A plugin can only set headers on requests that WordPress actually handles, which means static files and non-WordPress URLs miss out. The web server sets them on everything.
On nginx in your server block:
add_header Strict-Transport-Security "max-age=31536000; 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 "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self'; frame-ancestors 'none';" always;
The always flag matters. Without it, nginx skips the header on error responses (500s, 404s), which is exactly when you want the browser being told to lock things down.
On Apache in .htaccess (though on nginx this file is inert, which is what most modern WordPress hosts run):
Header always set Strict-Transport-Security "max-age=31536000; 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 "camera=(), microphone=(), geolocation=(), payment=(), usb=()"
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self'; frame-ancestors 'none';"
If you can't touch the web server config, a plugin like Redirection or a dedicated security-headers plugin will set them from inside WordPress. Not as clean as the server-level version, but better than nothing.
Testing that they actually work
After deploying, check your headers are set correctly with securityheaders.com. Enter your domain, and you'll get a graded report of what's set, what's missing, and what looks wrong.
The grade itself matters less than the detail. An A+ is nice, but a B with every header set sensibly for your site is genuinely better than an A+ that ships with a broken CSP.
Headers you don't need to worry about
A few headers turn up in old WordPress security guides and are either deprecated or actively counterproductive.
- X-XSS-Protection deprecated. Modern browsers ignore it, and in some historical cases it introduced vulnerabilities. Don't bother.
- Public-Key-Pins (HPKP) deprecated for good reason. Getting it wrong bricks your site for months. Don't set it.
- Expect-CT deprecated. Certificate Transparency is now enforced by default in browsers.
If you see these recommended, the guide is out of date. Skip them.
Where this fits
Security headers are cheap, high-value, and set-and-forget. Half an hour of work, no ongoing maintenance, and every visitor to your site benefits. It's one of the best value-for-effort things you can do on a WordPress site, and it's genuinely bewildering how many otherwise well-run sites don't have any of them set.
The Protect My WP handbook covers headers in detail in Chapter 6, along with the wider set of SSL and HTTPS configuration that makes the whole HTTPS story hang together properly. If this post left you wanting the reasoning at more depth, that's where it lives.
Get the book for £19.
Get the free WordPress Security Checklist
The security checks I'd run through on any WordPress site, delivered straight to your inbox.
Want to go deeper?
The first chapter of Protect My WP is free. Start with the foreword, then read Chapter 1 on hosting and server security. There is also a shorter guide that walks the same ground faster if you want the shape of the book first.