2026年11月12日

2026年11月12日

Nginx Security Headers: HSTS, CSP, X-Frame-Options and add_header

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

Adding security headers with Nginx's add_header lets you tell browsers things like "connect only over HTTPS" and "do not display this site inside another site's frame (iframe)". This article explains how to set the main security headers on an Nginx server running WordPress, and how to avoid the common failure where you think a header is set but it is not actually sent. The content is current as of October 2026 and was tested with Nginx 1.24 on Ubuntu 24.04.

Headers to set and example values

Header Purpose Example value that works well with WordPress
Strict-Transport-Security (HSTS) Forces connections to the domain over HTTPS only for the specified period max-age=31536000 (start with a short value)
Content-Security-Policy (CSP) Restricts where scripts, images and other resources may be loaded from Build one per site. Test with Report-Only first
X-Frame-Options Prevents the page from being displayed inside another site's iframe (clickjacking protection) SAMEORIGIN
X-Content-Type-Options Stops the browser from guessing a file's type and executing it nosniff
Referrer-Policy Controls how much of the referring URL is sent to other sites strict-origin-when-cross-origin
Permissions-Policy Blocks features such as camera, microphone and geolocation camera=(), microphone=(), geolocation=()

You do not need to add X-XSS-Protection; major browsers have removed the feature it controlled.

Two add_header behaviors to know first

1. If a lower level has add_header, the upper level’s headers are not inherited

The official Nginx documentation says add_header directives are inherited from the previous level "if and only if" there are no add_header directives on the current level. For example, even if you write security headers in the server block, a single add_header Cache-Control ... line in a location for static files means responses from that location get none of the security headers.

server {
    add_header X-Frame-Options "SAMEORIGIN" always;

    location ~* \.(css|js)$ {
        # This location does not send X-Frame-Options
        add_header Cache-Control "public, max-age=2592000";
    }
}

The reliable approach is to put the headers in one file and include that same file in every location that has its own add_header (Step 1).

From Nginx 1.29.3, add_header_inherit merge; lets you add the upper level's headers to the current level's. As of October 2026, the standard Ubuntu 24.04 package is 1.24, so this article uses a method that works on any version.

2. Without always, headers are not added to error pages

By default, add_header adds headers only for specific status codes such as 200, 301, 302 and 304. To add them to 404 and 500 pages as well, append always.

Step 1: Put the headers in one file

Create /etc/nginx/snippets/security-headers.conf with the following content.

# /etc/nginx/snippets/security-headers.conf
add_header Strict-Transport-Security "max-age=300" 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;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;

The HSTS value max-age=300 (5 minutes) is for testing. You will lengthen it in Step 3.

Step 2: Include it in the HTTPS server block

Include the file in the port 443 server block of your site configuration (for example, /etc/nginx/sites-available/example.com). If any location has its own add_header, include it there as well.

server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://example.com$request_uri;
}

server {
    listen 443 ssl;
    http2 on;
    server_name example.com;
    # ssl_certificate and other settings omitted

    include /etc/nginx/snippets/security-headers.conf;

    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    location ~* \.(css|js|png|jpe?g|gif|webp|svg|woff2?)$ {
        include /etc/nginx/snippets/security-headers.conf;
        add_header Cache-Control "public, max-age=2592000";
    }

    # Keep your existing location ~ \.php$ { ... } as is
}

http2 on; is the syntax for Nginx 1.25.1 and later. On 1.24, write listen 443 ssl http2;. If you already have a working configuration, you can keep its syntax.

Putting HSTS in the port 80 server block does nothing. The specification (RFC 6797) requires browsers to ignore HSTS received over a non-HTTPS connection. On port 80, just redirect to HTTPS.

Test the configuration, then reload.

sudo nginx -t && sudo systemctl reload nginx

Step 3: Lengthen the HSTS max-age

A browser that receives HSTS connects to the domain only over HTTPS for max-age seconds. If the certificate expires or the HTTPS setup breaks, the site cannot be opened for that period, so confirm everything works with a short value before lengthening it.

Stage Value What to confirm
Test max-age=300 The whole site (admin, images, external integrations) works over HTTPS
Extend max-age=86400 (1 day) → max-age=2592000 (30 days) No problems for several days to a few weeks
Production max-age=31536000 (1 year) Automatic certificate renewal is working

Adding includeSubDomains applies the same restriction to every subdomain. If even one subdomain does not support HTTPS (an internal system, for example), it becomes unreachable, so check all of them before adding it.

If you add preload and register at hstspreload.org, browsers ship with your domain built in as HTTPS-only. Registration requires a max-age of at least 31536000 seconds and both includeSubDomains and preload. Removal depends on browser updates and can take months, so it is not easy to undo. For details, see forcing HTTPS and HSTS preload in WordPress: setup steps and risks.

Step 4: Set X-Frame-Options to SAMEORIGIN

WordPress itself sends X-Frame-Options: SAMEORIGIN on the login screen and admin screens. Using the same SAMEORIGIN in Nginx keeps the values consistent.

With DENY, pages cannot be shown even in iframes on the same site. The Customizer (Appearance → Customize) previews the site in an iframe, so the preview may stop appearing. Use DENY only if you know your own site never uses iframes.

Step 5: Test CSP with Report-Only first

CSP is powerful, but it blocks everything you have not allowed. The WordPress admin, the block editor and many plugins use inline scripts, so adding Content-Security-Policy right away can break screens.

If you first send Content-Security-Policy-Report-Only, the browser blocks nothing and only reports violations. Violations appear in the console of the browser's developer tools. The following example excludes the admin and login screens and applies the header only to public pages. Put the map in the http block (outside the server block; the top of the file in sites-available also works).

map $request_uri $csp_report_only {
    default "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; object-src 'none'; base-uri 'self'; frame-ancestors 'self'";
    ~^/wp-admin/      "";
    ~^/wp-login\.php  "";
}

Add the following line to security-headers.conf. When the value is empty, Nginx does not add the header, so it is not sent on the admin and login screens.

add_header Content-Security-Policy-Report-Only $csp_report_only always;

Open the public pages one by one, look at the violations in the console (external sources such as Google Analytics, ads, embedded videos and web fonts), and add those sources to script-src, img-src and so on. When no more violations appear, rename the header to Content-Security-Policy to enforce it.

frame-ancestors 'self' plays the same role as X-Frame-Options: SAMEORIGIN; when both are present, browsers that support it give priority to CSP. How to build a CSP for WordPress is covered in detail in how to set up a Content Security Policy (CSP) in WordPress.

Step 6: Confirm the headers are sent

Check each type of page. Static files and error pages in particular often miss the headers because of the two behaviors above.

# Home page
curl -sI https://example.com/ | grep -iE '^(strict-transport|content-security|x-frame|x-content-type|referrer|permissions)'

# Static file (check that a location's add_header did not drop them)
curl -sI https://example.com/wp-includes/css/dist/block-library/style.min.css | grep -iE '^(strict-transport|x-frame|cache-control)'

# Non-existent URL (check that a 404 still has them)
curl -sI https://example.com/no-such-page-12345/ | grep -iE '^(HTTP|strict-transport|x-frame)'

If the same header appears twice, both Nginx and a WordPress plugin, or a CDN, are adding it. Add it in only one place. If you use a CDN such as Cloudflare, also check whether the CDN settings add headers.

Troubleshooting

Symptom Cause and fix
Only some files lack the headers That location has its own add_header. Include the file there
Headers missing on 404 and similar pages always is missing
HSTS has no effect It is in the port 80 server. Put it in the port 443 one
The admin or editor stopped working CSP also applies to the admin. Go back to Report-Only and exclude the admin
The Customizer preview does not appear X-Frame-Options: DENY is set. Change it to SAMEORIGIN
Changes do not take effect Did you run systemctl reload nginx after nginx -t? Is the file you edited loaded from sites-enabled?

Summary

  • Put the headers in one file and include it in the port 443 server and in every location that has its own add_header
  • Append always so error pages get the headers too
  • Put HSTS only on port 443 and extend it from max-age=300 to one year in stages. Add includeSubDomains and preload only after checking every subdomain
  • On WordPress, use X-Frame-Options: SAMEORIGIN
  • Send CSP as Content-Security-Policy-Report-Only on public pages only, add allowances while watching violations, then enforce it
  • Check the home page, a static file and a 404 separately with curl -sI

Related articles:

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

お気軽にご相談ください

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