How to Add Security Headers to Your Web App
Protect your app with essential HTTP security headers
TL;DR
TL;DR (20 minutes)
Add security headers to protect against clickjacking, XSS, MIME sniffing, and protocol downgrade attacks. Essential headers: X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Strict-Transport-Security, Referrer-Policy, and Content-Security-Policy. Configure via your web server, framework middleware, or hosting platform.
Prerequisites
- Access to your web server configuration, framework code, or hosting platform dashboard
- Your site served over HTTPS (required for HSTS)
- Basic understanding of HTTP headers
- A way to test headers (browser DevTools or online scanner)
Why Security Headers Matter
HTTP security headers instruct browsers how to handle your site's content. Without them, your app is vulnerable to:
- Clickjacking - Attackers embed your site in an invisible iframe to hijack clicks
- MIME sniffing attacks - Browsers misinterpret file types, potentially executing malicious content
- XSS attacks - Malicious scripts run in your users' browsers stealing data
- Protocol downgrade - Attackers force HTTP connections to intercept traffic
- Information leakage - Sensitive URLs exposed via referrer headers
Essential Security Headers Reference
| Header | Recommended Value | Protection |
|---|---|---|
| X-Content-Type-Options | nosniff | Prevents MIME type sniffing |
| X-Frame-Options | DENY or SAMEORIGIN | Blocks clickjacking via iframes |
| Strict-Transport-Security | max-age=31536000; includeSubDomains | Enforces HTTPS connections |
| Referrer-Policy | strict-origin-when-cross-origin | Limits referrer information leakage |
| Content-Security-Policy | default-src 'self'; ... | Controls resource loading, prevents XSS |
| Permissions-Policy | camera=(), microphone=(), geolocation=() | Restricts browser feature access |
Step-by-Step Implementation
Express.js with Helmet
The easiest way to add security headers in Express is using the Helmet middleware:
npm install helmet
const express = require('express');
const helmet = require('helmet');
const app = express();
// Add all security headers with sensible defaults
app.use(helmet());
// Or configure individually:
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://api.example.com"],
fontSrc: ["'self'", "https://fonts.gstatic.com"],
objectSrc: ["'none'"],
upgradeInsecureRequests: [],
},
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
},
}));
app.listen(3000);
Next.js Configuration
Add headers in next.config.js:
/** @type {import('next').NextConfig} */
const nextConfig = {
async headers() {
return [
{
source: '/(.*)',
headers: [
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'X-Frame-Options',
value: 'DENY',
},
{
key: 'X-XSS-Protection',
value: '1; mode=block',
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=()',
},
{
key: 'Strict-Transport-Security',
value: 'max-age=31536000; includeSubDomains',
},
{
key: 'Content-Security-Policy',
value: "default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https:; frame-ancestors 'none';",
},
],
},
];
},
};
module.exports = nextConfig;
Next.js Middleware (Dynamic Headers)
For dynamic CSP with nonces, use middleware in middleware.ts:
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
style-src 'self' 'nonce-${nonce}';
img-src 'self' blob: data: https:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`.replace(/\s{2,}/g, ' ').trim();
const response = NextResponse.next();
response.headers.set('Content-Security-Policy', cspHeader);
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
response.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
response.headers.set('X-Nonce', nonce);
return response;
}
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
};
nginx Configuration
Add to your nginx server block:
server {
listen 443 ssl http2;
server_name example.com;
# Security Headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header X-XSS-Protection "1; mode=block" 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=31536000; includeSubDomains; preload" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; frame-ancestors 'none';" always;
# ... rest of your config
}
Apache Configuration
Add to your .htaccess or virtual host config:
<IfModule mod_headers.c>
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "DENY"
Header always set X-XSS-Protection "1; mode=block"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:;"
</IfModule>
Cloudflare Configuration
Add via Transform Rules in Cloudflare Dashboard:
- Go to Rules > Transform Rules > Modify Response Header
- Create a new rule for all requests
- Add each header as a "Set static" operation
Or use Cloudflare Workers:
export default {
async fetch(request, env) {
const response = await fetch(request);
const newResponse = new Response(response.body, response);
newResponse.headers.set('X-Content-Type-Options', 'nosniff');
newResponse.headers.set('X-Frame-Options', 'DENY');
newResponse.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
newResponse.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
newResponse.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
newResponse.headers.set('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:;");
return newResponse;
},
};
Security Checklist Before Deployment
- Ensure your site is fully served over HTTPS before enabling HSTS
- Test Content-Security-Policy in report-only mode first
- Verify X-Frame-Options doesn't break legitimate iframe embeds
- Check that third-party scripts are allowed in your CSP
- Start with a short HSTS max-age (e.g., 300) and increase gradually
- Test on staging environment before production deployment
How to Verify It Worked
Method 1: Browser DevTools
- Open your site in Chrome or Firefox
- Press F12 to open DevTools
- Go to the Network tab
- Reload the page (Ctrl+R or Cmd+R)
- Click on the first request (your HTML document)
- Scroll down to Response Headers
- Verify all your security headers are present
Method 2: Online Scanner
Use securityheaders.com to scan your site:
- Enter your URL and click Scan
- Review your grade (aim for A or A+)
- Check which headers are missing or misconfigured
Method 3: Command Line
# Using curl to check headers
curl -I https://yoursite.com
# Expected output includes:
# x-content-type-options: nosniff
# x-frame-options: DENY
# strict-transport-security: max-age=31536000; includeSubDomains
# referrer-policy: strict-origin-when-cross-origin
# content-security-policy: default-src 'self'; ...
Common Errors and Troubleshooting
Headers not appearing
- CDN caching: Your CDN may be caching responses without headers. Purge the cache or add headers at the CDN level.
- Wrong config location: Ensure headers are configured for all routes, not just specific paths.
- Server not restarted: nginx and Apache require restart/reload after config changes.
CSP blocking content
- Inline scripts blocked: Add
'unsafe-inline'to script-src (less secure) or use nonces. - Third-party resources blocked: Add the domain to the appropriate directive (e.g.,
script-src 'self' https://cdn.example.com). - Images not loading: Add
data:andhttps:to img-src for base64 and external images.
HSTS causing issues
- Can't access HTTP version: This is expected behavior. If you need to disable HSTS, set max-age=0 and wait for the original max-age to expire.
- Subdomain not HTTPS-ready: Remove
includeSubDomainsuntil all subdomains support HTTPS.
X-Frame-Options breaking embeds
- Legitimate embeds blocked: Use
SAMEORIGINinstead ofDENY, or use CSPframe-ancestorsfor more control. - Need to allow specific domains: Use
Content-Security-Policy: frame-ancestors 'self' https://trusted.com
Pro Tip
Use Content-Security-Policy-Report-Only header first to test your CSP without breaking anything. Set up a reporting endpoint to collect violations and refine your policy before enforcing it.
Frequently Asked Questions
What security headers should every website have?
Every website should have: X-Content-Type-Options: nosniff, X-Frame-Options: DENY or SAMEORIGIN, Strict-Transport-Security with max-age of at least 31536000, Referrer-Policy: strict-origin-when-cross-origin, and Content-Security-Policy. These protect against the most common web attacks.
Will adding security headers break my website?
Basic headers like X-Content-Type-Options, X-Frame-Options, and Referrer-Policy rarely cause issues. HSTS can cause problems if your site isn't fully HTTPS-ready. CSP is most likely to break functionality if misconfigured - always test in report-only mode first.
Do security headers work on static sites?
Yes, security headers work on static sites. Configure them through your hosting platform: vercel.json for Vercel, _headers file for Netlify, or through your CDN's configuration. Headers are sent by the server regardless of content type.
What is the difference between X-Frame-Options and CSP frame-ancestors?
Both prevent clickjacking. X-Frame-Options only supports DENY, SAMEORIGIN, or ALLOW-FROM (deprecated). CSP frame-ancestors supports multiple domains and wildcards. Use both for browser compatibility, but prefer frame-ancestors for new implementations.
Do I need all these headers if I use a framework like React or Next.js?
Yes. Frameworks help with some security aspects but don't add HTTP headers automatically. You must configure security headers separately through your hosting platform or server configuration.
Check Your Security Headers
Run a free scan to see which security headers your site is missing and get specific recommendations.
Start Free Scan