2026年11月12日

2026年11月12日

WordPress Security Headers: .htaccess, Nginx and functions.php Setup

This article is a localized edition of the Japanese original. Read the Japanese original

HTTP security headers are response headers that tell the browser how to treat a page. They reduce clickjacking, MIME type confusion, information leaking through the referrer, and downgrades to HTTP. On WordPress you set them in Apache's .htaccess, in the Nginx configuration, or in functions.php. This article shows the correct way to write each, and explains why headers sometimes do not appear or the site turns into a 500 error.

The main headers to set

Header Purpose Example value
X-Frame-Options Prevents other sites from embedding the page in an iframe (clickjacking protection) SAMEORIGIN
X-Content-Type-Options Stops the browser from guessing the MIME type and treating a file as something else nosniff
Referrer-Policy Controls how much referrer information is sent when moving to other sites strict-origin-when-cross-origin
Permissions-Policy Restricts browser features such as the camera, microphone and geolocation camera=(), microphone=(), geolocation=()
Strict-Transport-Security (HSTS) Tells the browser to use only HTTPS for future visits Start at max-age=300 and increase it

Do not set X-XSS-Protection: 1; mode=block, which you often see in older articles. Major browsers have already removed the XSS filter this header controls, and MDN and others recommend not sending it (or sending 0 if you do). Protect against XSS with a Content Security Policy (CSP) instead.

If you set X-Frame-Options to DENY, features that show your own site in an iframe, such as the Customizer preview, stop working. Use SAMEORIGIN for WordPress admin screens. As of October 2026, WordPress itself already sends X-Frame-Options: SAMEORIGIN and Content-Security-Policy: frame-ancestors 'self' on the admin and login screens.

Before you start: check the current headers

First check which headers are returned now. Replace example.com with your domain.

curl -sI https://example.com/ | grep -iE "x-frame|x-content|strict-transport|referrer|permissions|content-security|x-powered-by|^server"

You can also check the response headers of the page in the Network tab of your browser's developer tools.

Setting them in Apache (.htaccess)

Add the following to the .htaccess file in the WordPress root, before # BEGIN WordPress. mod_headers must be enabled (on your own Debian/Ubuntu server, enable it with sudo a2enmod headers; on shared hosting it is usually enabled).

<IfModule mod_headers.c>
    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=()"

    # Send HSTS only over HTTPS. Start with max-age=300 (5 minutes)
    Header always set Strict-Transport-Security "max-age=300" "expr=%{HTTPS} == 'on'"

    # Remove the X-Powered-By header added by PHP (the root fix is expose_php = Off in php.ini)
    Header unset X-Powered-By
</IfModule>

Settings that must not go into .htaccess

Some examples put ServerTokens Prod in .htaccess to hide server information, but ServerTokens is a directive that can only be used in the server-wide configuration (such as httpd.conf). In .htaccess it turns the whole site into a 500 Internal Server Error. On your own server, put it in the server configuration and restart, like this:

# Example: /etc/apache2/conf-available/security.conf on Debian/Ubuntu
ServerTokens Prod
ServerSignature Off

On shared hosting, you usually cannot change this setting yourself.

X-Powered-By is added by PHP, so the proper fix is expose_php = Off in php.ini. Some shared hosts let you change it in their php.ini settings panel.

Setting them in Nginx

In Nginx, use add_header. With always, the headers are also added to error pages (4xx and 5xx).

server {
    # ... existing settings ...

    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=()" always;
    add_header Strict-Transport-Security "max-age=300" always;

    # Hide the version number (the name "nginx" itself remains)
    server_tokens off;

    location ~ \.php$ {
        # ... existing fastcgi settings ...
        # Remove X-Powered-By added by PHP-FPM (available in the standard module)
        fastcgi_hide_header X-Powered-By;
    }
}

Some examples remove headers with more_clear_headers, but that directive belongs to the headers-more module, which is not part of standard Nginx. Without the module, nginx -t fails with an unknown directive error and the configuration cannot be applied. To remove PHP's X-Powered-By, fastcgi_hide_header above is enough.

The pitfall of disappearing add_header

If an inner block (such as a location) contains even one add_header, Nginx does not inherit the add_header directives from the outer server block. For example, if you set add_header Cache-Control ... inside a location for image caching, responses from that location carry no security headers. In that case, collect the security headers in a separate file and include it in each location.

# List the security header add_header lines in /etc/nginx/snippets/security-headers.conf
location ~* \.(?:jpg|jpeg|png|gif|webp|svg|css|js)$ {
    add_header Cache-Control "public, max-age=2592000";
    include snippets/security-headers.conf;
}

After editing, check the syntax with sudo nginx -t and apply it with sudo systemctl reload nginx.

Setting them in functions.php (if you cannot change the server configuration)

If you can touch neither .htaccess nor the Nginx configuration, you can send the headers with WordPress's send_headers action.

<?php
// functions.php (or an mu-plugin): send security headers from PHP
add_action( 'send_headers', function () {
	header( 'X-Frame-Options: SAMEORIGIN' );
	header( 'X-Content-Type-Options: nosniff' );
	header( 'Referrer-Policy: strict-origin-when-cross-origin' );
	header( 'Permissions-Policy: camera=(), microphone=(), geolocation=()' );
	if ( is_ssl() ) {
		header( 'Strict-Transport-Security: max-age=300' );
	}
	header_remove( 'X-Powered-By' );
} );

// Remove the X-Pingback header added on single posts
add_filter( 'wp_headers', function ( $headers ) {
	unset( $headers['X-Pingback'] );
	return $headers;
} );

This method has the following limits.

  • send_headers runs when WordPress renders a front-end page. The headers are not added to admin screens or to static files such as images, CSS and JavaScript that the web server returns directly.
  • The Server header is added by the web server, so PHP's header_remove() cannot remove it.
  • If a page caching plugin or a CDN returns the HTML directly, pages served from the cache may not carry the headers sent by PHP.

Set the headers on the server side (.htaccess or Nginx) whenever possible, and do not set them in both PHP and the server. If you do, the same header can be sent twice.

Start HSTS with a short duration

A browser that receives Strict-Transport-Security connects to the site only over HTTPS for max-age seconds. If HTTPS stops working, for example because the certificate expired, the site cannot be opened during that time. Increasing it in this order is the safe approach.

  1. Set max-age=300 (5 minutes) and check that the site, the admin screens and any subdomains work
  2. If there are no problems, increase it to max-age=86400 (1 day), then max-age=2592000 (30 days)
  3. Finally set max-age=31536000 (1 year)

With includeSubDomains, the same instruction applies to every subdomain. Do not add it if any subdomain does not support HTTPS. Registering for the HSTS preload list takes a long time to undo, so consider it only after operation is stable.

Check which features you use before setting Permissions-Policy

Empty parentheses, as in camera=(), disable the feature everywhere on the page. If your site uses a feature (for example, your own script that uses geolocation), specify who may use it, as in geolocation=(self). After setting it, check that the main features, such as contact forms and embedded content, still work.

Checking the result

curl -sI https://example.com/ | grep -iE "x-frame|x-content|strict-transport|referrer|permissions|x-powered-by"

If you get output like the following, the headers are set (values depend on your configuration).

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

Check not only the front page but also a post, an image URL, and a URL that does not exist (the 404 page). If the headers are missing on the 404 page, suspect a missing always in Apache or Nginx; if they are missing on images, suspect Nginx's add_header inheritance.

Summary

  • The basic five are X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy and Strict-Transport-Security. Do not set X-XSS-Protection
  • Do not put ServerTokens in .htaccess (it causes a 500 error). Put it in the server configuration
  • Nginx's more_clear_headers needs the headers-more module. Remove X-Powered-By with fastcgi_hide_header or expose_php = Off
  • Nginx's add_header is not inherited from the outer block when the inner block has its own add_header
  • send_headers in functions.php does not cover admin screens or static files
  • Start HSTS at max-age=300 and increase it after confirming nothing breaks

Related articles:

Questions about WordPress? The publisher of this site is based in Japan and replies in Japanese. Contact (Japanese)

お気軽にご相談ください

お見積りへ お問い合わせへ
▲