A security audit flagged one of our production apps last year. The app itself was solid — input validation, parameterized queries, proper authentication. But the auditor ran a header check and came back with a list: no Content-Security-Policy, no HSTS, no X-Content-Type-Options. The app was serving responses with basically zero browser-side protection. We fixed it in an afternoon, but it was embarrassing that we’d shipped to production without these basics.
HTTP security headers are the easiest security win you’ll ever get. Most take a single line to add, they protect against real attack vectors, and they work across all modern browsers. Here’s what you need and why.
Why Headers Matter
Your server sends HTTP response headers with every page. Security headers instruct the browser on how to behave — what to load, what to block, whether to upgrade connections. Without them, the browser uses its most permissive defaults, leaving your users exposed to:
- Cross-site scripting (XSS) — malicious scripts executing in your page context
- Clickjacking — your page embedded in a hidden iframe to steal clicks
- Protocol downgrade attacks — forcing HTTPS connections back to HTTP
- MIME type confusion — the browser interpreting uploaded files as executable scripts
Check your headers: Paste your site’s response headers into our HTTP Header Analyzer to get an instant security score with specific recommendations for each missing or misconfigured header.
Content-Security-Policy (CSP)
CSP is the most powerful security header — and the most complex to configure correctly. It tells the browser exactly which resources are allowed to load and execute on your page.
The Basics
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'
Each directive controls a resource type:
| Directive | Controls | Example |
|---|---|---|
default-src | Fallback for all resource types | 'self' |
script-src | JavaScript files and inline scripts | 'self' https://cdn.example.com |
style-src | CSS files and inline styles | 'self' 'unsafe-inline' |
img-src | Images | 'self' data: https: |
connect-src | Fetch, XHR, WebSocket destinations | 'self' https://api.example.com |
frame-src | Sources for iframes | 'none' |
font-src | Web fonts | 'self' https://fonts.gstatic.com |
Starting with CSP: The Report-Only Approach
Don’t deploy a strict CSP in production on day one — you’ll break things. Use Content-Security-Policy-Report-Only first:
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-reports
This logs violations without blocking anything. Review the reports, adjust the policy, and only then switch to enforcement.
Common CSP Mistakes
Mistake 1: Using unsafe-inline and unsafe-eval everywhere
# This CSP is almost useless — it allows inline scripts and eval()
Content-Security-Policy: script-src 'self' 'unsafe-inline' 'unsafe-eval'
These directives defeat the main purpose of CSP (blocking injected scripts). Instead, use nonces or hashes:
<!-- Server generates a random nonce per request -->
Content-Security-Policy: script-src 'nonce-abc123'
<script nonce="abc123">
// This script runs because the nonce matches
</script>
<script>
// This script is blocked — no matching nonce
alert('XSS attempt');
</script>
Mistake 2: Forgetting connect-src for API calls
Your frontend makes fetch/XHR requests to your API. Without connect-src, the browser may block these requests — and the error looks confusingly similar to a CORS failure.
Mistake 3: Not including frame-ancestors
frame-ancestors controls who can embed your page in an iframe. It’s the modern replacement for X-Frame-Options:
Content-Security-Policy: frame-ancestors 'self'
Strict-Transport-Security (HSTS)
HSTS tells the browser: “Always use HTTPS for this domain, even if the user types http://.”
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
| Parameter | Meaning |
|---|---|
max-age | How long (seconds) the browser remembers to use HTTPS |
includeSubDomains | Apply HSTS to all subdomains too |
preload | Eligible for the HSTS preload list (hardcoded in browsers) |
Why HSTS Matters
Without HSTS, even if your server redirects HTTP to HTTPS, the first request is still unencrypted. An attacker on the same network (coffee shop Wi-Fi, anyone?) can intercept that first request and perform a “SSL stripping” attack — downgrading the connection to HTTP permanently.
The Deployment Order
- Start with a short
max-age(300 seconds) to test - Verify everything works over HTTPS, including subdomains
- Increase to
max-age=31536000(1 year) - Add
includeSubDomains - Submit to the HSTS preload list — this gets your domain hardcoded into browsers
Warning: Once you’re on the preload list, removing HSTS is very difficult. Make sure every subdomain supports HTTPS first.
X-Content-Type-Options
X-Content-Type-Options: nosniff
This single header prevents “MIME type sniffing” — where the browser ignores the Content-Type header and guesses the file type from the content. An attacker could upload a file named avatar.jpg containing JavaScript, and a browser doing MIME sniffing might execute it as a script.
There’s only one valid value (nosniff), and there’s no reason not to set it on every response.
X-Frame-Options
Controls whether your page can be loaded in an iframe:
X-Frame-Options: DENY # Never allow framing
X-Frame-Options: SAMEORIGIN # Only allow framing by same origin
This prevents clickjacking — where an attacker embeds your page in a transparent iframe and tricks users into clicking buttons they can’t see.
Note: X-Frame-Options is being superseded by CSP’s frame-ancestors directive, but include both for compatibility with older browsers:
Content-Security-Policy: frame-ancestors 'self'
X-Frame-Options: SAMEORIGIN
Referrer-Policy
Controls how much referrer information is sent when users navigate away from your site:
Referrer-Policy: strict-origin-when-cross-origin
| Value | Behavior |
|---|---|
no-referrer | Never send referrer |
origin | Send only the origin (no path) |
strict-origin-when-cross-origin | Full URL for same-origin, origin only for cross-origin, nothing for downgrade |
same-origin | Full URL for same-origin only |
Recommended: strict-origin-when-cross-origin — it’s the browser default and a good balance between privacy and functionality.
Why this matters: Without a referrer policy, your full URL (including query parameters) leaks to every external link. If your URLs contain tokens, user IDs, or search queries, that’s sensitive data leaking to third parties.
Permissions-Policy (formerly Feature-Policy)
Controls which browser features your site can use:
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
The () means “nobody” — not even your own page. This prevents third-party scripts (analytics, ads) from accessing sensitive APIs:
Permissions-Policy: camera=(self), microphone=(), geolocation=(self "https://maps.example.com")
Implementation: Framework Examples
Express.js with Helmet
const helmet = require('helmet');
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://api.example.com"],
},
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
},
}));
Nginx
server {
# HSTS
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# CSP
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:;" always;
# Prevent MIME sniffing
add_header X-Content-Type-Options "nosniff" always;
# Clickjacking protection
add_header X-Frame-Options "SAMEORIGIN" always;
# Referrer policy
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Permissions policy
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
}
Next.js
// next.config.js
const securityHeaders = [
{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains; preload' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
];
module.exports = {
async headers() {
return [{
source: '/:path*',
headers: securityHeaders,
}];
},
};
Django
# settings.py
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = 'SAMEORIGIN'
SECURE_REFERRER_POLICY = 'strict-origin-when-cross-origin'
# CSP with django-csp
CSP_DEFAULT_SRC = ("'self'",)
CSP_SCRIPT_SRC = ("'self'",)
CSP_STYLE_SRC = ("'self'", "'unsafe-inline'")
CSP_IMG_SRC = ("'self'", "data:", "https:")
The Minimum Viable Security Headers
If you do nothing else, add these five headers to every response:
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Referrer-Policy: strict-origin-when-cross-origin
Content-Security-Policy: default-src 'self'
That’s five lines that protect against XSS, clickjacking, MIME sniffing, protocol downgrades, and referrer leakage. It takes five minutes to implement and defends against attack vectors that have been exploited in the wild for decades.
Further Reading
- CORS Errors Explained: Why Your Fetch Call Fails
- XSS Prevention with HTML Entity Encoding
- Encoding & Hashing: The Complete Guide
Want to check your site’s security headers? Paste your HTTP response headers into our HTTP Header Analyzer for an instant score and specific fix recommendations. Pair it with the SSL Certificate Checker for a complete security posture review.