Several years ago, I ran a WordPress site through Security Headers (securityheaders.com) expecting a
decent score. The result was an embarrassing F grade. The site was running on HTTPS, had been
updated regularly, and I thought I had security covered. But I’d completely overlooked HTTP security
headers—invisible directives that tell browsers how to protect visitors from common attacks.

Security headers are one of the most underutilized security measures. They require no application
code changes, take minutes to implement, and provide meaningful protection against XSS,
clickjacking, and various other attacks. Yet many sites—even security-conscious ones—don’t set them.

This guide covers each essential security header in depth. You’ll understand what each header
protects against, how to implement it on both Nginx and Apache, and how to test that your
configuration works. By the end, you’ll have the knowledge to achieve an A+ rating on security
scanners and, more importantly, genuinely protect your site and visitors.

Understanding How Security Headers Work

HTTP headers are metadata sent with every response from your server. When a browser requests a page,
the server responds with the page content plus headers that provide instructions. Regular headers
might specify content type, caching rules, or compression. Security headers specifically instruct
browsers to enable protective behaviors.

The key insight is that security headers work at the browser level. They don’t directly stop attacks
on your server—they tell browsers to refuse dangerous actions even if malicious content reaches the
page. If an XSS payload somehow gets injected into your page, Content-Security-Policy can prevent
the browser from executing it. If someone tries to frame your site for clickjacking, X-Frame-Options
tells the browser to refuse.

This makes security headers a defense-in-depth layer. They assume something else might fail—your
application code, your WAF, your input validation—and provide an additional barrier. That’s
precisely why they’re valuable: when other defenses fail, these headers often save the day.

Content-Security-Policy: The Most Powerful Header

Content-Security-Policy (CSP) tells the browser which content sources are legitimate for your page.
Scripts not from approved sources get blocked. Styles from unknown domains get rejected. This is XSS
protection that works even when your code has vulnerabilities.

How CSP Defeats XSS

Consider a typical XSS attack where an attacker injects a script tag into a comment or form field
that gets displayed on your page. Without CSP, the browser dutifully executes that script—it’s just
another script tag in the HTML. With a proper CSP that blocks inline scripts and restricts script
sources, the browser refuses to run it.

The attacker might inject <script>stealCookies()</script> into your page, but if your CSP
says “only execute scripts from my domain,” the browser ignores the injected script. The attack
fails not because you detected and blocked it, but because the browser enforces a policy that makes
it powerless.

CSP Directive Basics

CSP works through directives that control different content types. Each directive specifies allowed
sources for that content type.

default-src is the fallback for any directive you don’t specify explicitly. Setting default-src
‘self’ means “by default, only load content from my own domain.”

script-src controls where JavaScript can come from. The strictest setting is script-src ‘self’,
allowing only scripts served from your domain. For sites using Google Analytics, you’d add their
domains: script-src ‘self’ https://www.google-analytics.com https://www.googletagmanager.com.

style-src controls CSS sources. Many sites need ‘unsafe-inline’ for inline styles, but this reduces
protection. Where possible, move styles to external files.

img-src controls image sources. Besides ‘self’, you commonly need data: for inline images and any CDN
domains serving your images.

connect-src controls where JavaScript can make network requests (fetch, XMLHttpRequest, WebSocket).
Without proper connect-src, even if a malicious script runs, it can’t exfiltrate data to an
attacker’s server.

frame-ancestors controls which sites can embed your page in frames. This replaces X-Frame-Options
with more flexibility.

Building a Practical CSP

A typical WordPress site with Google Analytics, Google Fonts, and a few third-party services might
use a CSP like this:

default-src ‘self’; script-src ‘self’ ‘unsafe-inline’ https://www.google-analytics.com
https://www.googletagmanager.com; style-src ‘self’ ‘unsafe-inline’ https://fonts.googleapis.com;
font-src ‘self’ https://fonts.gstatic.com; img-src ‘self’ data: https://www.google-analytics.com;
connect-src ‘self’ https://www.google-analytics.com; frame-ancestors ‘none’;

This allows your own content, Google services you use, and explicitly blocks framing. The
‘unsafe-inline’ for scripts and styles is a compromise—many WordPress themes and plugins use inline
code heavily—but still provides meaningful protection by limiting sources.

CSP Report-Only Mode

Implementing a strict CSP on a live site without testing risks breaking functionality. The solution
is Content-Security-Policy-Report-Only header, which logs violations without blocking anything.

Deploy your intended CSP as report-only first. Set up a report endpoint (or use a service like
report-uri.com) to collect violations. Review what would be blocked, adjust your policy, and repeat
until violations are expected cases you understand. Then switch to enforcing mode.

This process is essential for complex sites. I’ve seen CSP deployments break payment integrations,
embedded videos, and third-party widgets because sources weren’t properly allowed. Report-only mode
catches these issues before they impact users.

Dealing with Inline Scripts

The ideal CSP bans inline scripts entirely because injected XSS payloads are inline. But many
applications depend on inline scripts for functionality. Two approaches address this:

Nonce-based CSP generates a random token for each response and includes it in allowed inline scripts.
CSP specifies script-src ‘nonce-abc123’, and legitimate inline scripts get that nonce as an
attribute: <script nonce=”abc123″>…</script>. Attackers can’t guess the nonce, so
injected scripts get blocked.

Hash-based CSP computes hashes of allowed inline scripts and includes them in CSP. Only scripts
matching those exact hashes run. This works for scripts that don’t change but is impractical for
dynamic content.

For WordPress sites, implementing nonces requires theme/plugin modifications and often isn’t
practical. Accepting ‘unsafe-inline’ while restricting sources still provides value—attackers can
inject scripts but those scripts can’t load external resources or send data to attacker servers
without violating connect-src.

Strict-Transport-Security: Forcing HTTPS

HTTP Strict Transport Security (HSTS) instructs browsers to never connect to your site over plain
HTTP. Even if a user types http://yoursite.com, the browser automatically converts to HTTPS before
making the request.

Why HSTS Matters

HTTPS alone isn’t sufficient because the first request might be HTTP. A user bookmarks
http://example.com, and their first request is unencrypted. Or an attacker uses SSL stripping,
intercepting that initial HTTP request and preventing the redirect to HTTPS.

HSTS solves this by remembering that your site is HTTPS-only. After one HTTPS response with HSTS, the
browser remembers for the specified duration and never attempts HTTP again—not even for the first
request.

Implementing HSTS

Basic HSTS requires one header: Strict-Transport-Security: max-age=31536000. The max-age specifies
how long (in seconds) browsers should remember. 31536000 seconds is one year—a common choice that
balances persistence against the ability to change if needed.

includeSubDomains extends protection to all subdomains: Strict-Transport-Security: max-age=31536000;
includeSubDomains. Be careful with this—if any subdomain can’t support HTTPS, it becomes
inaccessible.

Only set HSTS on HTTPS responses. If set on HTTP (before redirect), it won’t work. Configure your
server to add the header only when serving over HTTPS.

HSTS Preloading

Even HSTS has a vulnerability: that first request before the browser knows about your policy.
Preloading addresses this by including your domain in a browser-maintained list of HTTPS-only sites.
Browsers check this list before any request, ensuring HTTPS from the very first connection.

To preload, add the preload directive: Strict-Transport-Security: max-age=31536000;
includeSubDomains; preload. Then submit your domain at hstspreload.org.

Warning: preloading is very difficult to undo. Once on the list, browsers force HTTPS indefinitely.
If you later can’t maintain HTTPS (expired certificate, need to use HTTP temporarily), you’re stuck.
Only preload if you’re certain your entire domain will forever support HTTPS.

X-Frame-Options: Clickjacking Protection

Clickjacking attacks load your site in a hidden frame, overlaying deceptive content that tricks users
into clicking on your site’s buttons without realizing it. A user thinks they’re clicking “Win a
Prize” but they’re actually clicking “Transfer $1000” on your banking site loaded invisibly behind
it.

Implementing X-Frame-Options

X-Frame-Options controls whether browsers allow your pages to be framed. Three values are available:

DENY prevents all framing. Your pages can never appear in frames, anywhere. This is the safest option
for most sites: X-Frame-Options: DENY.

SAMEORIGIN allows framing by your own site only. Useful if you legitimately use iframes internally:
X-Frame-Options: SAMEORIGIN.

ALLOW-FROM specifies a single allowed domain. Note that this is deprecated and doesn’t work in all
browsers. Use CSP frame-ancestors instead for more flexibility.

Modern Alternative: CSP frame-ancestors

CSP’s frame-ancestors directive provides the same protection with more flexibility. You can specify
multiple allowed domains, use wildcards, and it integrates with your broader CSP strategy.

frame-ancestors ‘none’ equals DENY. frame-ancestors ‘self’ equals SAMEORIGIN. frame-ancestors ‘self’
https://partner-site.com allows specific external sites.

For maximum compatibility, set both X-Frame-Options (for older browsers) and CSP frame-ancestors (for
modern browsers). They work together without conflict.

X-Content-Type-Options: Preventing MIME Sniffing

MIME sniffing is a browser behavior where content is interpreted based on actual content rather than
declared Content-Type. This can be exploited: upload an HTML file with a .jpg extension, and in some
cases browsers might execute it as HTML.

The Simple Fix

X-Content-Type-Options: nosniff tells browsers to trust the Content-Type header and not try to guess.
A file served as image/jpeg is treated as an image, period, even if it contains valid HTML.

This is a single-value header with no configuration needed. Add it globally to all responses:
X-Content-Type-Options: nosniff.

Referrer-Policy: Controlling Information Leakage

When users click links from your site to external sites, browsers send a Referrer header indicating
where they came from. This can leak sensitive information—URLs might contain tokens, user IDs, or
other private data.

Referrer-Policy Options

no-referrer never sends referrer information. Maximum privacy but breaks analytics referral tracking.

no-referrer-when-downgrade is the default browser behavior—sends referrer over HTTPS but not when
navigating from HTTPS to HTTP.

strict-origin-when-cross-origin sends the full URL for same-origin requests, only the origin (domain)
for cross-origin HTTPS requests, and nothing for HTTP. This is the recommended balance of privacy
and functionality.

origin always sends only the origin, never the full path. Hides specific page paths from third
parties.

Recommended Configuration

For most sites, Referrer-Policy: strict-origin-when-cross-origin provides good protection while
maintaining compatibility with analytics. External sites see traffic came from your domain but not
which specific page.

Permissions-Policy: Browser Feature Controls

Formerly called Feature-Policy, Permissions-Policy controls which browser APIs and features your site
can use. If you don’t use geolocation, disable it. If you don’t need camera access, block it. This
limits damage if your site is compromised—an attacker can’t use features you’ve disabled.

Commonly Restricted Features

Disable features you don’t need: Permissions-Policy: accelerometer=(), camera=(), geolocation=(),
gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()

Empty parentheses mean no origins are allowed to use the feature. To allow only your origin, use
(self). To allow specific partners, list their domains.

Value for Security

If an XSS attack attempts to access the camera or request geolocation, the browser refuses based on
your policy. This is defense in depth—the attack might bypass other protections but still can’t
access these sensitive APIs.

Implementing Security Headers

Nginx Configuration

Create a security headers snippet file and include it in your server blocks. Place this in
/etc/nginx/snippets/security-headers.conf:

add_header Strict-Transport-Security “max-age=31536000; includeSubDomains” always;
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 “accelerometer=(), camera=(), geolocation=(), gyroscope=(),
magnetometer=(), microphone=(), payment=(), usb=()” always;

Then in your server block: include snippets/security-headers.conf;

CSP typically goes in the server block directly because it varies by site.

Apache Configuration

Add to .htaccess or your virtual host configuration within <IfModule mod_headers.c>:

Header always set Strict-Transport-Security “max-age=31536000; includeSubDomains”
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 “accelerometer=(), camera=(), geolocation=()”

WordPress Plugin Options

If you can’t modify server configuration, WordPress plugins like HTTP Headers or Really Simple SSL
include security header management. These add headers via PHP, which works but is slightly less
efficient than server-level configuration.

Testing Your Implementation

Online Scanners

securityheaders.com grades your implementation from F to A+, explaining each header’s contribution.
It’s the quickest way to verify basic implementation.

Mozilla Observatory (observatory.mozilla.org) provides a more comprehensive scan including CSP
evaluation and analysis of your overall security posture.

Google’s CSP Evaluator (csp-evaluator.withgoogle.com) specifically analyzes your CSP for weaknesses
and bypass possibilities.

Command Line Testing

curl -I https://example.com shows all response headers. Verify each security header appears with
correct values.

For specific headers: curl -sI https://example.com | grep -i “content-security-policy” shows just the
CSP header.

Browser Developer Tools

Open DevTools, go to the Network tab, load your page, click the main document request, and examine
the Response Headers section. All your security headers should appear.

The Console tab shows CSP violations—useful for debugging policies. If something breaks with CSP
enabled, the console logs which resource was blocked and which directive blocked it.

Troubleshooting Common Issues

CSP Breaking Functionality

When CSP blocks legitimate resources, identify the violation in browser console. The error message
specifies which directive blocked which resource. Add the necessary source to that directive.

Common issues: inline scripts/styles need ‘unsafe-inline’ or nonces; third-party services need their
domains added; data: URIs need explicit allowance in img-src.

HSTS Locking You Out

If you set HSTS and then have certificate problems, browsers refuse to connect. For testing, use
short max-age (like 300 seconds). Only increase to production values after confirming stable HTTPS.

If you’re stuck, clear HSTS in your browser. In Chrome, visit chrome://net-internals/#hsts, enter
your domain under “Delete domain security policies”, and click Delete.

Headers Not Appearing

Verify configuration is loaded—typos or missing include statements are common causes. Check that
headers are added in the right location (server block vs location block). Some CDNs strip or modify
headers; verify your CDN passes security headers through.

Conclusion

Security headers provide meaningful protection with minimal implementation effort. At minimum,
implement HSTS (forcing HTTPS), X-Frame-Options (preventing clickjacking), X-Content-Type-Options
(preventing MIME sniffing), and Referrer-Policy (preventing information leakage). These require
single lines of configuration and work immediately.

CSP requires more thought but provides the most powerful protection. Start with report-only mode,
refine based on reports, and deploy progressively stricter policies as you understand your
dependencies.

Test with security scanners after implementation and periodically thereafter—requirements change as
you add third-party services. An A+ score isn’t the goal in itself; the goal is real protection for
your site and visitors. Security headers achieve that with minimal complexity.