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_headersruns 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
Serverheader is added by the web server, so PHP'sheader_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.
- Set
max-age=300(5 minutes) and check that the site, the admin screens and any subdomains work - If there are no problems, increase it to
max-age=86400(1 day), thenmax-age=2592000(30 days) - 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-PolicyandStrict-Transport-Security. Do not setX-XSS-Protection - Do not put
ServerTokensin .htaccess (it causes a 500 error). Put it in the server configuration - Nginx's
more_clear_headersneeds the headers-more module. RemoveX-Powered-Bywithfastcgi_hide_headerorexpose_php = Off - Nginx's
add_headeris not inherited from the outer block when the inner block has its ownadd_header send_headersin functions.php does not cover admin screens or static files- Start HSTS at
max-age=300and increase it after confirming nothing breaks
Related articles:
- Setting security headers (HSTS/CSP/X-Frame-Options) in Nginx
- Setting HSTS in Apache: raising max-age step by step and preload caveats
- Testing a WordPress Content Security Policy (CSP) safely in report-only mode
Questions about WordPress? The publisher of this site is based in Japan and replies in Japanese. Contact (Japanese)