Fix Guide

How to Set Up HTTP Security Headers via .htaccess vs Nginx

May 20, 2026

HTTP security headers are response headers that tell browsers to enforce specific security behaviors when rendering a page. They are configured on the webserver, not in WordPress, and they cost no performance to enable. A correctly headered WordPress site blocks clickjacking, mixed-content downgrades, MIME-sniffing exploits, and a large share of cross-site scripting payloads. The setup is a one-time change in either Apache's .htaccess or Nginx's server config. Below are the seven headers that matter in 2026, with copy-paste-ready snippets for both webservers.

Which HTTP security headers should a WordPress site set?

Seven headers cover the realistic threat model for a content-driven site. In priority order:

  • Strict-Transport-Security (HSTS). Forces browsers to use HTTPS for one year (or longer) after the first visit, preventing HTTPS-stripping attacks on public WiFi. Mandatory if your site has any logged-in users.
  • Content-Security-Policy (CSP). Tells browsers exactly which sources of JavaScript, CSS, images and fonts are allowed. The most impactful header against XSS, but also the trickiest to configure without breaking your site.
  • X-Frame-Options or the CSP frame-ancestors directive. Prevents your site from being embedded in an iframe on another domain, blocking clickjacking attacks against the login form.
  • X-Content-Type-Options: nosniff. Stops browsers from guessing the content type of a response, which closes a class of "upload a fake image that is actually JavaScript" attacks.
  • Referrer-Policy. Controls how much of the current URL is sent in the Referer header on outbound clicks. Defaults leak query strings to third parties; strict-origin-when-cross-origin is the modern recommendation.
  • Permissions-Policy (formerly Feature-Policy). Disables browser features your site does not use (camera, microphone, geolocation), which reduces exposure if any third-party script ever tries to access them.
  • Cross-Origin-Opener-Policy. Isolates your site from any popup windows it opens, mitigating Spectre-class side-channel attacks. Required for SharedArrayBuffer to work.

What is no longer recommended: X-XSS-Protection (deprecated, browsers ignore it), Public-Key-Pins (deprecated, caused too many lockouts), and Expect-CT (deprecated as of 2023).

Apache .htaccess: copy-paste security header block

Add this to the top of your WordPress root .htaccess, above the WordPress block (# BEGIN WordPress). The IfModule guard ensures the snippet does not crash if mod_headers is missing.

<IfModule mod_headers.c>
    # HSTS: force HTTPS for 1 year, include subdomains, preload-ready
    Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"

    # Prevent clickjacking
    Header always set X-Frame-Options "SAMEORIGIN"

    # Block MIME-sniffing
    Header always set X-Content-Type-Options "nosniff"

    # Limit referrer leakage
    Header always set Referrer-Policy "strict-origin-when-cross-origin"

    # Disable unused browser features
    Header always set Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()"

    # Cross-origin isolation
    Header always set Cross-Origin-Opener-Policy "same-origin"

    # Conservative starter CSP; tighten once you know what your site loads
    Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https:; style-src 'self' 'unsafe-inline' https:; img-src 'self' data: https:; font-src 'self' data: https:; connect-src 'self' https:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';"
</IfModule>

The always keyword is critical. Without it, headers are only sent on 2xx and 3xx responses; with it, error pages and redirects also carry the headers. Without always, an attacker who triggers a 500 error sees an unprotected response.

The mod_headers module must be enabled. On shared hosting it usually already is. On a self-managed Apache: sudo a2enmod headers && sudo systemctl reload apache2.

Nginx: copy-paste security header block

Add this inside the server { ... } block of your site, typically in /etc/nginx/sites-available/yoursite.conf:

    # HSTS: force HTTPS for 1 year, include subdomains, preload-ready
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;

    # Prevent clickjacking
    add_header X-Frame-Options "SAMEORIGIN" always;

    # Block MIME-sniffing
    add_header X-Content-Type-Options "nosniff" always;

    # Limit referrer leakage
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    # Disable unused browser features
    add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;

    # Cross-origin isolation
    add_header Cross-Origin-Opener-Policy "same-origin" always;

    # Conservative starter CSP; tighten once you know what your site loads
    add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https:; style-src 'self' 'unsafe-inline' https:; img-src 'self' data: https:; font-src 'self' data: https:; connect-src 'self' https:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always;

The trailing always in Nginx serves the same purpose as in Apache: it makes the header apply to error responses too. Reload Nginx after the change: sudo nginx -t && sudo systemctl reload nginx.

The two-gotcha rule of Nginx add_header

Nginx has a quirk that bites people repeatedly. Two rules to internalize before touching production config:

  • add_header is replaced, not merged. If you set headers in the server block and again in a location block, the location-block headers replace the server-block ones for that location. They do not add to them. The symptom: headers work everywhere except inside a specific location (like /wp-admin/ where you added a custom rule).
  • Always add the always flag. Without it, a 404 page or a 500 error page is served without your security headers. Attackers can deliberately trigger errors to bypass header-based protections.

Apache's Header always set is the equivalent of Nginx's always flag and has the same effect.

Apache vs Nginx: which is easier to configure?

Practically equivalent for security headers. Both use a single directive per header and accept identical header values. Three differences worth knowing:

  • Where the config lives. Apache via .htaccess can be edited per-directory by anyone with FTP access to the WordPress install. Nginx config lives outside the document root and usually requires SSH plus a reload. For shared WordPress hosting, .htaccess is more practical; for managed Nginx setups, the server config is more performant (Nginx re-reads .htaccess-equivalent rules on every request, but the proper nginx config is cached in memory).
  • Per-location overrides. Apache .htaccess automatically inherits the parent directory's settings. Nginx location blocks need explicit re-declaration if you customize one (because of the merge gotcha above).
  • Hosting reality. Most shared WordPress hosting (Hostinger, IONOS, GoDaddy, traditional cPanel hosts) is Apache. Most managed WordPress hosting (Kinsta, WP Engine, Raidboxes, SiteGround, Cloudways) is Nginx. The percentage split has shifted from majority-Apache to roughly 50/50 over the last five years.

Should I add security headers in WordPress instead of the webserver?

You can, but webserver level is better. Three WordPress-side options exist:

  • Security plugin (Wordfence, iThemes Security, etc.). Has a "security headers" feature that adds the same headers PHP-side. Works but adds a few milliseconds per request. Useful if you do not have webserver access.
  • Custom plugin or theme code. A small mu-plugin can call header() in PHP to emit each header. Same overhead, same caveats.
  • Webserver config (recommended). Zero PHP overhead, applies to every response including 404 errors and static files, survives WordPress crashes.

The case for webserver-level: headers added in PHP do not protect responses that bypass PHP. If an attacker reaches a static file directly (a leftover .phps backup, an upload directory with HTML files), PHP-set headers are not applied. The webserver sees every request and is the right layer.

How do I tune the Content-Security-Policy without breaking my site?

CSP is the most impactful security header and also the most likely to break things. A WordPress site loads scripts and styles from dozens of sources by default: jQuery from /wp-includes, theme assets, plugin assets, Google Fonts (if not self-hosted), analytics, embedded YouTube, etc. A strict CSP that does not whitelist these explicitly will block them and the site will appear broken in subtle ways (login fails, gallery does not work, analytics stops).

The two-phase rollout pattern that works:

  1. Start in report-only mode. Use Content-Security-Policy-Report-Only instead of Content-Security-Policy. The browser does not block anything; it just logs violations to the developer console. Browse your site for a few days, monitor what would have been blocked, and add the legitimate sources to your policy.
  2. Switch to enforcing mode. Once the report-only mode shows zero violations on a normal browse, change the header name back to Content-Security-Policy. Now violations are blocked.

One common trap: WordPress and many plugins use inline JavaScript event handlers (onclick="") and inline <script> blocks. A strict CSP requires either 'unsafe-inline' (defeats most of the protection) or nonces for every inline script (significant code changes). The pragmatic middle ground in 2026: keep 'unsafe-inline' for now, but lock down the other directives. Future WordPress versions are moving toward nonce-friendly inline scripts.

How do I verify my security headers are working?

Four methods, from fastest to most authoritative:

  1. InspectWP report. The Security section lists every header your site sets, its value, and flags missing or misconfigured ones. One screen.
  2. curl from the command line. curl -I https://yoursite.com prints the response headers. Look for the seven headers above; verify their values.
  3. securityheaders.com. A free public scanner that grades your site (A+ to F) and explains which headers are missing or weak. Industry standard for header audits.
  4. Browser DevTools. Open DevTools (F12), Network tab, click the document request, look at the Response Headers section. Useful for seeing CSP violations live in the Console tab.

Common mistakes and pitfalls

  • Adding HSTS without confirming HTTPS works site-wide. HSTS forces browsers to use HTTPS for a year. If your site has a broken cert or a subpage that only works over HTTP, users get locked out. Test HTTPS thoroughly before enabling HSTS. Start with a short max-age (like 300 seconds) and ramp up to one year.
  • Setting X-Frame-Options AND CSP frame-ancestors. Both work but frame-ancestors is the modern equivalent. Setting both is redundant but harmless; modern browsers prefer frame-ancestors.
  • Forgetting includeSubDomains in HSTS. If your main site is HTTPS but a subdomain still serves HTTP, attackers can pivot through the subdomain. includeSubDomains forces HTTPS everywhere under your domain. Verify all subdomains are HTTPS before adding this.
  • Copying a CSP from a generic tutorial. Every WordPress site loads different third-party assets. A generic CSP will break your site. Use report-only mode first.
  • Mixing CDN and origin headers. If you have Cloudflare in front, you can set headers at Cloudflare (Page Rules or Transform Rules) or at the origin. Setting both with different values causes confusion when debugging. Pick one place and document it.
  • Forgetting to reload after edit. Nginx changes do nothing until systemctl reload nginx. Apache .htaccess changes are instant; Apache main config changes require a reload.

What about HTTP/3 and the modern header story?

HTTP/3 does not change anything about which headers to set; the header semantics are identical across HTTP/1.1, HTTP/2 and HTTP/3. One header that becomes relevant on HTTP/3 is Alt-Svc, which advertises HTTP/3 availability to clients that arrived over HTTP/2. Most webservers add this automatically.

The browser landscape is also gradually moving toward enforcing modern defaults even when headers are missing. Chrome and Firefox now default to strict-origin-when-cross-origin for Referrer-Policy if no header is sent, and increasingly assume HTTPS for any site that has ever been served over HTTPS. Setting the headers explicitly is still recommended because the defaults vary by browser version and because non-browser HTTP clients (curl, scripts, monitoring tools) do not implement these defaults.

What InspectWP checks

The Security section of every InspectWP report inspects the response headers of your main document and reports which of the standard security headers are present, what their values are, and which are missing or set to non-recommended values. Missing HSTS on an HTTPS site is flagged as a warning; missing X-Content-Type-Options is flagged as informational; a CSP set to default-src * (effectively no policy at all) is flagged as a misconfiguration. The report also notes when headers are set at the CDN level versus the origin level, since that affects what you would need to change to update them. The recommended state is all seven headers present with reasonable values, and CSP either in report-only mode while you tune it or in enforcing mode once tuned.

Check your WordPress site now

InspectWP analyzes your WordPress site for security issues, SEO problems, GDPR compliance, and performance — for free.

Analyze your site free