How to add security headers to a WordPress site
Security headers are lines a server sends with each page that tell the browser how to treat it. Add X-Content-Type-Options, Referrer-Policy and a frame rule first, then HSTS with a short max-age, and Content-Security-Policy last, in report-only mode.
- By
- WP Ministry
- Published
In short
- Three headers carry little risk and go first. They are X-Content-Type-Options, Referrer-Policy and a frame rule.
- HSTS is a promise a browser holds you to. Start with a max-age of five minutes and raise it in stages.
- Once a Content-Security-Policy restricts scripts, inline scripts stop running. Start it in report-only mode.
- Set each header in one place. That is the web server, WordPress or a CDN, not two of them.
- Leave out X-XSS-Protection, Expect-CT and Public-Key-Pins.
- Headers instruct browsers. A missing one does not mean a site was broken into, and none replaces updates and strong logins.
Security headers are lines a web server sends along with a page. They tell the browser how to treat it: whether to insist on https, whether another site may show the page inside a frame, how much to tell other sites about where a visitor came from. WordPress sends a frame rule and a referrer policy on its login and admin screens and leaves the public pages to you.
Add them in order of risk. Three carry little: X-Content-Type-Options, Referrer-Policy and a frame rule. Strict-Transport-Security comes next, with a short max-age at first, because a browser holds you to it. Content-Security-Policy comes last and starts in report-only mode, because a policy that restricts scripts stops inline scripts from running.
See what your site sends now
From a terminal, curl -I asks for the headers only. Use your own address.
curl -I https://example.com/The answer is a status line, then one header on each line. Capital letters in a name make no difference: x-frame-options and X-Frame-Options are the same header.
Without a terminal, the WordPress site health check reads one page from outside. It lists which of three protective headers the page does not send: X-Content-Type-Options, a frame rule (X-Frame-Options, or frame-ancestors in a Content-Security-Policy) and Referrer-Policy. On an https page it also says when Strict-Transport-Security is not sent. It does not judge the rest of a Content-Security-Policy.
Where the headers are set
There are three places. Use one for each header, because a header set in two places is sent twice.
- Apache. The
.htaccessfile in the site's main folder. Put the lines above# BEGIN WordPress. WordPress replaces what sits between that line and# END WordPress, so anything you add inside the block is lost. The lines need Apache'smod_module.headers - nginx. Inside the site's
serverblock, the one that answers on https. Test the file withnginx -t, then apply it withnginx -s reload. On managed hosting, ask the host to add the lines. - WordPress. For hosts where you can edit neither file. Put a PHP file in
wp-content/, creating the folder if it is not there. WordPress loads every PHP file that sits directly in that folder, with nothing to activate. To switch it off, delete the file.mu-plugins
The WordPress route reaches less. It adds headers only to the pages WordPress builds. Files the web server sends by itself, such as images, stylesheets and scripts, do not get them, and a page cache that answers before WordPress runs can leave them off as well.
If your host's control panel or a CDN has its own setting for one of these headers, set it there and not in the file too.
Start with three low-risk headers
.htaccess
<IfModule mod_headers.c>
Header onsuccess unset X-Content-Type-Options
Header always set X-Content-Type-Options "nosniff"
Header onsuccess unset Referrer-Policy
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header onsuccess unset X-Frame-Options
Header always set X-Frame-Options "SAMEORIGIN"
</IfModule>Four details in those lines matter.
always, on both servers. Without it, Apache adds a header only to successful responses, and nginx only to a fixed list of status codes that leaves out 404 and 500. With it, redirects and error pages carry the header too.- The
unsetlines on Apache. Apache keeps two lists of headers, and a header that WordPress sent through PHP can sit in the other list from yours. Theunsetline clears it there, so the header goes out once. <IfModule>on Apache. Ifmod_is not loaded, the lines are skipped and nothing breaks. Ifheaders curlshows no change, ask the host to turn the module on.add_inside aheader locationblock on nginx. A block that has anadd_of its own, such as one that setsheader Cache-Controlfor static files, inherits none from theserverblock. Repeat the three lines there. From nginx 1.29.3,add_changes that rule.header_ inherit merge;
X-Content-Type-Options
Every file arrives with a Content-Type that says what it is. Without this header a browser may look inside the file and decide for itself, which is how an uploaded file labeled as plain text can end up treated as a web page. nosniff tells the browser to believe the label.
What it can break: a script or stylesheet sent with the wrong type is blocked. If one stops loading after you add the header, its Content-Type is wrong, and that is the thing to fix.
Referrer-Policy
When a visitor follows a link away from your site, or a page loads a file from another host, the browser tells that host where the request came from. With strict-origin-when-cross-origin, other sites are told your domain and not the full address of the page, and nothing at all when the request goes from https to plain http. Requests within your own site still carry the full address.
Browsers already behave this way when no policy is set, which is why the risk is low. Sending the header makes it explicit. Stricter values such as same-origin and no-referrer hide your domain as well, so the sites you link to no longer see that a visit came from you.
A frame rule
X-Frame-Options: SAMEORIGIN lets a page be shown inside a frame only by pages of the same site. It stops another site from loading yours in a frame and tricking a visitor into clicking on it. WordPress sends this value itself on the login and admin screens. DENY refuses your own site too.
What it breaks is anything that shows your pages in a frame on another domain, such as a partner site that embeds your booking page. The old ALLOW-FROM value for naming a permitted site is obsolete, and current browsers ignore a header that uses it. Remove the X-Frame-Options lines and send the frame-ancestors directive of Content-Security-Policy in their place, the same way as the headers above:
Content-Security-Policy: frame-ancestors 'self' https://partner.example.comThis directive governs framing only. It does not restrict scripts, so it carries none of the risk described further down.
Add HSTS once https is solid
Strict-Transport-Security, or HSTS, tells a browser to use https for your domain for the next max-age seconds. Until that time is up, the browser turns every http address for the site into https before it asks, and it gives the visitor no way to click past a certificate error. Each response that carries the header restarts the clock.
That is the danger. If the certificate lapses, or https stops working, visitors whose browsers hold the policy cannot reach the site until it is fixed or the time runs out. With include the policy covers every subdomain too, including a forgotten one that answers only on http.
Before you send it, check three things.
- http is sent on to https. Give the site health check your address beginning with
http://. It reports "http is sent on to https" when the redirect is in place. - The certificate renews without you. A lapsed one is described in how to fix "Your connection is not private". With HSTS in force, visitors cannot get past that warning.
- Mixed content is cleared. HSTS covers addresses on your own domain only. A file asked for over http from another host is still mixed content. How to fix mixed content warnings has the steps.
Browsers ignore the header on plain http, so it counts only on https responses.
.htaccess
<IfModule mod_headers.c>
Header always set Strict-Transport-Security "max-age=300"
</IfModule>Step 1: Start at five minutes
Send
max-age=and confirm with300 curl -Ithat the header is there. Use the site as you normally do for a day or two. If something goes wrong at this stage, remove the line. Browsers forget the policy five minutes after their last visit.Step 2: Raise it in stages
Change the number to a week, then a month, then a year. Wait out each stage before you move to the next.
textmax-age=604800 one week max-age=2592000 one month max-age=31536000 one yearStep 3: Add include
Sub Domains only when every subdomain is on https List every subdomain that exists, including ones for staging and old projects. When each answers on https with a valid certificate, the value becomes
max-age=.31536000; include Sub Domains Step 4: Leave preload out unless you have decided on it
preloadasks for the domain to be built into browsers themselves, after you submit it at hstspreload.org. The list requires a valid certificate, a redirect from http, every subdomain on https, and a header with amax-ageof at least a year,includeandSub Domains preload. Removal takes months to reach visitors. The list's own site says not to addpreloadby default.
To undo HSTS, change the value to max-age= and keep the site working on https. Each browser drops the policy the next time it receives the header over https. It cannot be switched off over plain http.
Content-Security-Policy: start in report-only mode
Content-Security-Policy tells the browser which sources a page may load scripts, styles, images and other files from. It is the header aimed at injected scripts, and the one that needs the most care.
Once a policy contains default-src or script-src, the browser stops running inline JavaScript: code written between <script> tags in the page, handlers such as onclick in the markup, and javascript: links. WordPress has a function that themes and plugins call to print inline script, wp_. To see how much your own pages carry, view the source of your home page and look for <script> tags that hold code and have no src.
The keyword that lets inline script back in, 'unsafe-inline', gives up most of what the policy is for. The sound alternative is a nonce or a hash on every inline script, with inline handlers and javascript: links removed. That is development work, and it is hard when the scripts come from plugins you do not control.
So send the policy in report-only mode first. Nothing is blocked. The browser only reports what would have been.
.htaccess
<IfModule mod_headers.c>
Header always set Content-Security-Policy-Report-Only "default-src 'self'"
</IfModule>Step 1: Open the site with the browser's console showing
Open the browser's developer tools and choose the console. A report-only policy shows its errors there, the same as an enforced one.
Step 2: Visit every kind of page
Load the home page, a post, a page with a form, and the cart and checkout if you have a store. If the header is set at the server, log in and open the dashboard and the editor too, because it is sent there as well.
Step 3: Sort what the console reports
Each message names something the policy would have blocked. Sort them into two lists: inline scripts and styles, and files from other hosts, such as fonts, analytics, embedded video and payment scripts.
Step 4: Decide what to enforce
If the list is short and a developer can deal with each inline script, write a policy that allows exactly what is on it. Run that in report-only mode until the console is quiet, then rename the header to
Content-Security-Policy. If the list is long, do not enforce a script policy. Remove the report-only line and send only the two directives that leave scripts alone.textContent-Security-Policy: frame-ancestors 'self'; upgrade-insecure-requests
upgrade-insecure-requests makes the browser ask for the page's http addresses over https. A file that is not available on https then fails to load, so clear mixed content first here too.
The console shows your own visits only. To collect reports from real visitors, a policy needs a report-to directive and an address that receives the reports.
Permissions-Policy and the headers to leave out
Permissions-Policy switches browser features off for the page and for every frame inside it. OWASP's example turns off three:
Permissions-Policy: geolocation=(), camera=(), microphone=()Leave out any feature the site uses, such as location on a store finder. MDN marks the header as experimental and not supported in every widely used browser, so treat it as optional.
Three older headers should not be sent. OWASP's guidance is not to set X-XSS-Protection, or to set it to 0, because it can create weaknesses in sites that are otherwise safe. It also says not to use Expect-CT or Public-Key-Pins.
Check after each change
Run curl -I again, three times: on a page, on the address of an image or stylesheet, and on an address that does not exist. You should see a line for each header you added, such as x-frame-options: SAMEORIGIN. With the server routes all three answers carry them. With the WordPress route only pages do.
If something stops working, remove the lines you added last and reload.
What headers do not do
- They do not change the server. A header is an instruction to the visitor's browser. It does not patch a plugin, stop password guessing or remove malware.
- A missing header is not a break-in. It means a refinement has not been added. For real signs of trouble, see how to check whether your WordPress site has been hacked.
- They do not replace updates and strong logins. WordPress security: what to do, in order of what matters most has what comes first. A care plan under our WordPress security service keeps the site updated, scanned and backed up offsite.
Common questions
A scan says my site is missing security headers. Has it been hacked?
No. The scan is reporting that the server does not send certain instructions to browsers. It says nothing about whether anyone has got into the site. Add the ones it names that are on this page, in the order given here.
Why does a header show twice?
Two layers are setting it, such as the server and a CDN. WordPress itself sends X-Frame-Options and Referrer-Policy on the login and admin screens. On Apache, the unset lines above deal with that. On nginx, fastcgi_ in the block that passes requests to PHP drops PHP's copy. Anywhere else, remove the header from one of the two layers.
I turned on HSTS and part of the site will not load. What can I do?
Get https working on the address that fails, with a valid certificate. Nothing else clears it for visitors, because the browser will not go back to http and offers no way past the warning. Once https works, send max-age= from the main domain if you want out of the policy.
Can I add these headers with a meta tag?
Not these. X-Frame-Options in a meta tag has no effect, frame-ancestors is not supported there, and neither is report-only mode. Send them as real headers.

