Docs

Add security headers

Add security headers

A few response headers tell the browser how to protect your visitors: which scripts it may run, whether it may guess a file's type, whether another site may load your page in a frame, and how much of the address it passes on when someone clicks a link. Without them, a single injected script or a hidden frame on someone else's site (clickjacking) works against your visitors exactly as the attacker planned.

The headers cost nothing to send and nobody notices them, until one of them stops an attack. The site checklist looks for the four basic ones.

What the checklist looks for

Header Safe value to start with What it does
Content-Security-Policy frame-ancestors 'self'; base-uri 'self'; object-src 'none' Limits what the page may load and who may frame it
X-Content-Type-Options nosniff The browser must not treat a text file as a script
X-Frame-Options SAMEORIGIN Only your own site may show the page in a frame
Referrer-Policy strict-origin-when-cross-origin Other sites see only your domain, not the full address

X-Frame-Options also counts as present when the Content-Security-Policy contains frame-ancestors, which is the modern replacement. Sending both does no harm.

Start Content-Security-Policy small

A full Content-Security-Policy lists every domain the page may load scripts, styles, fonts and images from. Get it wrong and the browser silently blocks Google Tag Manager, analytics, the cookie banner, chat widgets or payment forms. That is why the value in the table above is deliberately small: it controls framing, the <base> tag and old plugins (<object>), and leaves scripts alone. It cannot break GTM.

If you want a policy that also controls scripts, build it in two steps:

  1. Send it as Content-Security-Policy-Report-Only first, next to the small enforcing header. The browser blocks nothing, but every violation appears in the developer console (F12 → Console).
  2. Browse the site for a few days, including checkout, forms and pages with embeds, and add each domain you see to the policy. GTM is the hard part: every tag you add in GTM can load from a new domain, so a strict policy has to be updated with every new tag.

Only then switch the header name to Content-Security-Policy. The checklist counts only the enforcing header; Report-Only alone stays yellow.

Warning: Do not copy a strict CSP from another website. Its list of allowed domains is not yours, and the first thing it breaks is usually analytics or payments, where nobody looks until the numbers drop.

Check it yourself

Replace example.com with your site:

curl -sI https://example.com | grep -iE '^(content-security-policy|x-content-type-options|x-frame-options|referrer-policy):'

You should see all four lines (or three, if Content-Security-Policy contains frame-ancestors). A header that appears twice with different values usually means it is set in two places, for example in the server and in a plugin. Keep one.

How to fix

Pick the place you have access to. Headers set on the web server or the CDN cover every page and every file, including pages served from cache, so prefer those over the CMS.

WordPress

WordPress has no setting for these headers. Add them with the free Code Snippets plugin (Snippets → Add New, run everywhere) or at the end of your child theme's functions.php:

add_action('send_headers', function () {
    header("Content-Security-Policy: frame-ancestors 'self'; base-uri 'self'; object-src 'none'");
    header('X-Content-Type-Options: nosniff');
    header('X-Frame-Options: SAMEORIGIN');
    header('Referrer-Policy: strict-origin-when-cross-origin');
});

This runs only when WordPress itself builds the response. Caching plugins such as WP Rocket or LiteSpeed Cache often serve a stored copy of the page without starting PHP, and that copy goes out without the headers. If the check still shows them missing after you clear the cache, set them on the server or in Cloudflare instead (see those tabs).

cPanel

cPanel has no screen for response headers. Add them to the .htaccess file in the site's root folder (File Manager, with Show Hidden Files on), at the top of the file:

<IfModule mod_headers.c>
    Header always set Content-Security-Policy "frame-ancestors 'self'; base-uri 'self'; object-src 'none'"
    Header always set X-Content-Type-Options "nosniff"
    Header always set X-Frame-Options "SAMEORIGIN"
    Header always set Referrer-Policy "strict-origin-when-cross-origin"
</IfModule>

This works on Apache and on LiteSpeed, which reads .htaccess the same way. Save, then run the check. If nothing changed, the host has mod_headers disabled; ask support to enable it.

Plesk

Websites & Domains → example.com → Apache & nginx Settings, in Additional nginx directives add:

add_header Content-Security-Policy "frame-ancestors 'self'; base-uri 'self'; object-src 'none'" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

Click OK; Plesk checks the syntax and reloads nginx. If an application on the site (WordPress plugin, framework) already sends one of these headers, the response will carry it twice. Remove it in one of the two places.

nginx

Put the headers in the site's server { … } block: on Debian and Ubuntu in /etc/nginx/sites-available/example.com, on Rocky, Alma and CentOS in /etc/nginx/conf.d/example.com.conf:

server {
    # …
    add_header Content-Security-Policy "frame-ancestors 'self'; base-uri 'self'; object-src 'none'" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}

always adds them to error pages and redirects too. Watch for one nginx trap: a location block that has its own add_header (for example for cache headers) drops all headers from the server level for the files it matches. Repeat the four lines in such a block, or keep them in a file and include it in both places.

sudo nginx -t && sudo systemctl reload nginx

Apache and LiteSpeed

With server access (Apache). Enable mod_headers (on Debian and Ubuntu: sudo a2enmod headers) and add the headers to the site's <VirtualHost *:443>: on Debian and Ubuntu in /etc/apache2/sites-available/example.com-le-ssl.conf or similar, on Rocky, Alma and CentOS in /etc/httpd/conf.d/:

Header always set Content-Security-Policy "frame-ancestors 'self'; base-uri 'self'; object-src 'none'"
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "SAMEORIGIN"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
sudo apachectl configtest && sudo systemctl reload apache2

(httpd instead of apache2 on Rocky, Alma and CentOS.)

Without server access (shared hosting, LiteSpeed). Put the same four lines in the .htaccess in the site's root, wrapped in <IfModule mod_headers.c> … </IfModule> as in the cPanel tab.

Cloudflare

If the site runs behind Cloudflare (orange cloud), Cloudflare can add the headers on the free plan, for every page and file, whatever the origin sends:

  1. Open the domain and go to Rules → Transform Rules → Modify Response Header.
  2. Create rule, name it Security headers, apply it to All incoming requests.
  3. Add four entries with Set static: header name and value as in the table above (for example X-Content-Type-Options and nosniff), then Deploy.

Cloudflare also has a managed transform called Add security headers. It sets X-Content-Type-Options, X-Frame-Options and a Referrer-Policy, but not Content-Security-Policy, so you still need the rule for that one.

Node.js and Next.js

Next.js: in next.config.js:

const securityHeaders = [
    { key: 'Content-Security-Policy', value: "frame-ancestors 'self'; base-uri 'self'; object-src 'none'" },
    { key: 'X-Content-Type-Options', value: 'nosniff' },
    { key: 'X-Frame-Options', value: 'SAMEORIGIN' },
    { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
];

module.exports = {
    async headers() {
        return [{ source: '/:path*', headers: securityHeaders }];
    },
};

Express: a small middleware before your routes:

app.use((req, res, next) => {
    res.set({
        'Content-Security-Policy': "frame-ancestors 'self'; base-uri 'self'; object-src 'none'",
        'X-Content-Type-Options': 'nosniff',
        'X-Frame-Options': 'SAMEORIGIN',
        'Referrer-Policy': 'strict-origin-when-cross-origin',
    });
    next();
});

The popular helmet package sets these and more in one line, but its default Content-Security-Policy is strict and blocks scripts from other domains, Google Tag Manager included. Configure its contentSecurityPolicy option before you rely on it.

Laravel

Create a middleware, for example app/Http/Middleware/SecurityHeaders.php:

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class SecurityHeaders
{
    public function handle(Request $request, Closure $next): Response
    {
        $response = $next($request);

        $response->headers->set('Content-Security-Policy', "frame-ancestors 'self'; base-uri 'self'; object-src 'none'");
        $response->headers->set('X-Content-Type-Options', 'nosniff');
        $response->headers->set('X-Frame-Options', 'SAMEORIGIN');
        $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');

        return $response;
    }
}

Register it for every request in bootstrap/app.php (Laravel 11 and newer):

->withMiddleware(function (Middleware $middleware) {
    $middleware->append(\App\Http\Middleware\SecurityHeaders::class);
})

Files in public/ (CSS, JavaScript, images) are served by the web server without Laravel, so they do not get these headers. For the checklist that is fine: it looks at the page.

IIS and ASP.NET

In the site's web.config:

<configuration>
  <system.webServer>
    <httpProtocol>
      <customHeaders>
        <add name="Content-Security-Policy" value="frame-ancestors 'self'; base-uri 'self'; object-src 'none'" />
        <add name="X-Content-Type-Options" value="nosniff" />
        <add name="X-Frame-Options" value="SAMEORIGIN" />
        <add name="Referrer-Policy" value="strict-origin-when-cross-origin" />
      </customHeaders>
    </httpProtocol>
  </system.webServer>
</configuration>

If the application also sets one of them in code, IIS sends both values. Keep one.

Hosted platforms

On Shopify, Wix, Squarespace and Webflow the platform sends the response headers and you cannot add your own. If the checklist reports a header missing there, there is nothing to change on your side; the item stays a warning and does not affect anything else.

When the page is meant to be framed

SAMEORIGIN and frame-ancestors 'self' stop every other site from showing your page in a frame. If a partner site, a booking widget or your own other domain embeds the page on purpose, list it: frame-ancestors 'self' https://example.org. Then drop X-Frame-Options: it cannot list other domains, and older browsers that read only it would block the embed.

Confirm the fix

Run the curl command from Check it yourself again, then open the target in PulseKeeper and click Check now on the Checklist tab. The item turns OK within a few minutes instead of at the next daily run. Open a few pages in the browser with the console open (F12) to make sure nothing is blocked.