---
title: "How to add security headers to a WordPress site"
url: https://wpministry.co/blog/set-up-wordpress-security-headers/
description: "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."
lang: en-US
---

Skip to content

# 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

October 7, 2026

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

bash

```
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 (https://wpministry.co/tools/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 `.htaccess` file 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's `mod_headers` module.
- **nginx.** Inside the site's `server` block, the one that answers on https. Test the file with `nginx -t`, then apply it with `nginx -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/mu-plugins`, 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.

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>
```

Use the one that matches where you set headers. The WordPress file is new: create it with exactly this content.

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 `unset` lines on Apache.** Apache keeps two lists of headers, and a header that WordPress sent through PHP can sit in the other list from yours. The `unset` line clears it there, so the header goes out once.
- **`<IfModule>` on Apache.** If `mod_headers` is not loaded, the lines are skipped and nothing breaks. If `curl` shows no change, ask the host to turn the module on.
- **`add_header` inside a `location` block on nginx.** A block that has an `add_header` of its own, such as one that sets `Cache-Control` for static files, inherits none from the `server` block. Repeat the three lines there. From nginx 1.29.3, `add_header_inherit merge;` changes that rule.

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

text

```
Content-Security-Policy: frame-ancestors 'self' https://partner.example.com
```

This 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 `includeSubDomains` 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" (https://wpministry.co/errors/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 (https://wpministry.co/errors/mixed-content/) 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>
```

Five minutes to begin with. In the WordPress file, add the line inside the function, above the return line.

1. ### Step 1: Start at five minutes
   Send `max-age=300` and confirm with `curl -I` that 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.
2. ### 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.
   text Copy

   ```
   max-age=604800      one week
   max-age=2592000     one month
   max-age=31536000    one year
   ```

3. ### Step 3: Add includeSubDomains 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; includeSubDomains`.
4. ### Step 4: Leave preload out unless you have decided on it
   `preload` asks for the domain to be built into browsers themselves, after you submit it at https://hstspreload.org/. The list requires a valid certificate, a redirect from http, every subdomain on https, and a header with a `max-age` of at least a year, `includeSubDomains` and `preload`. Removal takes months to reach visitors. The list's own site says not to add `preload` by default.

To undo HSTS, change the value to `max-age=0` 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_add_inline_script`. 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>
```

A policy that allows only the site's own files, in report-only mode. In the WordPress file, the line goes inside the function.

1. ### 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.
2. ### 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.
3. ### 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.
4. ### 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.
   text Copy

   ```
   Content-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:

text

```
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 (https://wpministry.co/blog/check-wordpress-site-hacked/).
- **They do not replace updates and strong logins.** WordPress security: what to do, in order of what matters most (https://wpministry.co/blog/ultimate-wordpress-security-guide/) has what comes first. A care plan under our WordPress security service (https://wpministry.co/services/wordpress-security/) 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_hide_header X-Frame-Options;` 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=0` 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.

## More on this subject

- WordPress security and hacked sites: https://wpministry.co/topics/wordpress-security/
- Glossary term Nonce: https://wpministry.co/glossary/nonce/
- Glossary term Salts and security keys: https://wpministry.co/glossary/salts-and-security-keys/
- Glossary term SSL certificate: https://wpministry.co/glossary/ssl-certificate/
- Glossary term Two-factor authentication (2FA): https://wpministry.co/glossary/two-factor-authentication/
- Glossary term Web application firewall (WAF): https://wpministry.co/glossary/web-application-firewall/
- Glossary term XML-RPC: https://wpministry.co/glossary/xml-rpc/

## Not sure what is wrong?

Tell us what you see. We reply with the cause and a fixed quote, and the diagnosis is free.

Get a free diagnosis: https://wpministry.co/free-diagnosis/

May we measure how this site is used?

A yes notes the pages of this visit and loads Google Analytics. A no keeps and loads nothing. See the cookies page (https://wpministry.co/legal/cookies/).

If you say yes, we note the pages you look at during this visit and load Google Analytics. If you say no, nothing is kept and nothing is loaded. The site works the same either way. The cookies page (https://wpministry.co/legal/cookies/) has the details.

## Structured data

```json
{
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": "Organization",
      "@id": "https://wpministry.co/#organization",
      "name": "WP Ministry",
      "url": "https://wpministry.co/",
      "logo": "https://wpministry.co/brand/logo.png",
      "address": {
        "@type": "PostalAddress",
        "streetAddress": "1309 Coffeen Ave STE 1200",
        "addressLocality": "Sheridan",
        "addressRegion": "WY",
        "postalCode": "82801",
        "addressCountry": "US"
      },
      "sameAs": [
        "https://www.linkedin.com/company/wp-ministry/",
        "https://x.com/wpministryco",
        "https://www.instagram.com/wpministryco/",
        "https://www.facebook.com/wpministryco",
        "https://www.tiktok.com/@wpministryco",
        "https://www.pinterest.com/wpministryco/",
        "https://www.youtube.com/channel/UCOZRuAukk5QSTRPG6BaHl3Q"
      ]
    },
    {
      "@type": "WebSite",
      "@id": "https://wpministry.co/#website",
      "name": "WP Ministry",
      "url": "https://wpministry.co/",
      "inLanguage": "en-US",
      "publisher": {
        "@id": "https://wpministry.co/#organization"
      }
    },
    {
      "@type": "Article",
      "@id": "https://wpministry.co/blog/set-up-wordpress-security-headers/#article",
      "headline": "How to add security headers to a WordPress site",
      "description": "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.",
      "url": "https://wpministry.co/blog/set-up-wordpress-security-headers/",
      "mainEntityOfPage": "https://wpministry.co/blog/set-up-wordpress-security-headers/",
      "inLanguage": "en-US",
      "image": "https://wpministry.co/og/blog/set-up-wordpress-security-headers.png",
      "author": {
        "@id": "https://wpministry.co/#organization"
      },
      "publisher": {
        "@id": "https://wpministry.co/#organization"
      },
      "datePublished": "2026-10-07T00:00:00.000Z",
      "dateModified": "2026-10-07T00:00:00.000Z"
    },
    {
      "@type": "BreadcrumbList",
      "itemListElement": [
        {
          "@type": "ListItem",
          "position": 1,
          "name": "Home",
          "item": "https://wpministry.co/"
        },
        {
          "@type": "ListItem",
          "position": 2,
          "name": "Guides",
          "item": "https://wpministry.co/blog/"
        },
        {
          "@type": "ListItem",
          "position": 3,
          "name": "How to add security headers to a WordPress site"
        }
      ]
    }
  ]
}
```