How to Add Security Headers (Nginx, Apache, Cloudflare, Vercel)
So you've run your site through a security headers checker and the grade wasn't great. Now what? The good news is that adding security headers is mostly configuration—no code changes required. The exact steps depend on your hosting setup, so I'll cover the most common ones.
Before you start
A few things to keep in mind:
- Test in staging first if possible. Some headers (especially CSP) can break functionality if misconfigured.
- HSTS is sticky. Once a browser sees HSTS, it remembers for the duration of
max-age. Start with a short max-age (like 300 seconds) until you're confident everything works. - CSP is the tricky one. If you're new to this, add all the other headers first, then tackle CSP separately.
Nginx configuration
Nginx makes it easy—just add headers in your server block. Edit your site's config file (usually in /etc/nginx/sites-available/ or /etc/nginx/conf.d/):
server {
listen 443 ssl http2;
server_name example.com;
# Security headers
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" 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" always;
# Content Security Policy (adjust as needed)
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; object-src 'none'; frame-ancestors 'none';" always;
# ... rest of your config
}
The always parameter ensures headers are sent even on error responses (4xx, 5xx).
After editing, test and reload:
sudo nginx -t
sudo systemctl reload nginx
If you're using include directives or have nested location blocks, be aware that add_header in a nested block overrides headers from parent blocks. You may need to repeat headers in each location.
Apache / .htaccess
For Apache, you can add headers in your .htaccess file (if AllowOverride permits) or directly in your VirtualHost config.
Using .htaccess
<IfModule mod_headers.c>
Header always set X-Frame-Options "DENY"
Header always set X-Content-Type-Options "nosniff"
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'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; object-src 'none'; frame-ancestors 'none';"
</IfModule>
Make sure mod_headers is enabled:
sudo a2enmod headers
sudo systemctl restart apache2
In VirtualHost config
Same headers, but in your site's config file (typically /etc/apache2/sites-available/yoursite.conf):
<VirtualHost *:443>
ServerName example.com
Header always set X-Frame-Options "DENY"
Header always set X-Content-Type-Options "nosniff"
# ... rest of headers
# ... rest of VirtualHost config
</VirtualHost>
Cloudflare configuration
Cloudflare can add security headers for you—no server changes needed. This is useful if you can't modify your origin server or want Cloudflare to handle it.
Using Transform Rules (recommended)
- Go to your Cloudflare dashboard
- Select your domain
- Navigate to Rules → Transform Rules
- Click Create rule under "Modify Response Header"
- Set up each header:
| Header name | Operation | Value |
|---|---|---|
| X-Frame-Options | Set static | DENY |
| X-Content-Type-Options | Set static | nosniff |
| Referrer-Policy | Set static | strict-origin-when-cross-origin |
| Permissions-Policy | Set static | camera=(), microphone=(), geolocation=() |
| Strict-Transport-Security | Set static | max-age=31536000; includeSubDomains |
You can add multiple headers in a single rule. Set the filter to "All incoming requests" unless you need path-specific headers.
Cloudflare's free plan includes Transform Rules. You get 10 rules for free, which is usually enough for security headers.
Using Workers (advanced)
For more control, you can use a Cloudflare Worker:
addEventListener('fetch', event => {
event.respondWith(handleRequest(event.request))
})
async function handleRequest(request) {
const response = await fetch(request)
const newResponse = new Response(response.body, response)
newResponse.headers.set('X-Frame-Options', 'DENY')
newResponse.headers.set('X-Content-Type-Options', 'nosniff')
newResponse.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
newResponse.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
newResponse.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains')
return newResponse
}
Deploy this to your domain's route and it'll add headers to all responses.
Vercel / Next.js
For Vercel deployments, you have two options: vercel.json or Next.js middleware/config.
Using vercel.json
Create or edit vercel.json in your project root:
{
"headers": [
{
"source": "/(.*)",
"headers": [
{ "key": "X-Frame-Options", "value": "DENY" },
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "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" }
]
}
]
}
Using next.config.js (Next.js)
/** @type {import('next').NextConfig} */
const nextConfig = {
async headers() {
return [
{
source: '/(.*)',
headers: [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ 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';"
}
]
}
]
}
}
module.exports = nextConfig
Note: Next.js often requires 'unsafe-eval' and 'unsafe-inline' for CSP due to how it handles scripts and styles. You may need to adjust based on your setup.
Netlify
For Netlify, create or edit netlify.toml in your project root:
[[headers]]
for = "/*"
[headers.values]
X-Frame-Options = "DENY"
X-Content-Type-Options = "nosniff"
Referrer-Policy = "strict-origin-when-cross-origin"
Permissions-Policy = "camera=(), microphone=(), geolocation=()"
Strict-Transport-Security = "max-age=31536000; includeSubDomains"
Or use a _headers file in your publish directory:
/*
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Strict-Transport-Security: max-age=31536000; includeSubDomains
Testing your implementation
After deploying changes, verify the headers are actually being sent:
Browser DevTools
- Open DevTools (F12 or right-click → Inspect)
- Go to the Network tab
- Refresh the page
- Click on the main document request
- Look at the Response Headers section
Command line
curl -I https://yoursite.com
This shows all response headers. Look for your security headers in the output.
Online checker
Run your site through our Security Headers Checker again. You should see your grade improve.
Troubleshooting common issues
Headers not appearing
- Cache: Clear your CDN/server cache. Old responses might still be served.
- Config syntax: Check for typos. One wrong quote can break the whole config.
- Module not loaded: For Apache, ensure
mod_headersis enabled. - Wrong location: In Nginx, check if headers are being overridden by nested blocks.
Site broken after adding CSP
CSP is strict by default. If your site breaks:
- Check browser console for CSP violation errors
- They'll tell you exactly what's being blocked
- Add the blocked sources to your CSP (or use
'unsafe-inline'for quick fixes) - Consider using
Content-Security-Policy-Report-Onlyfirst to log without blocking
HSTS causing issues
If you enabled HSTS and now can't access your site:
- Clear browser HSTS cache: In Chrome, go to
chrome://net-internals/#hstsand delete your domain - For browsers that don't have this, clear all browsing data or use a different browser
- This is why testing with a short
max-agefirst is important
What about older headers?
You might see recommendations for these headers:
- X-XSS-Protection: Deprecated but harmless. Include it for legacy browser support.
- Expect-CT: Deprecated as of June 2021. Certificate Transparency is now enforced by default.
- Public-Key-Pins: Removed from browsers. Don't use it.
Next steps
Once you've got the basic headers working:
- Tighten your CSP gradually. Start permissive, then remove allowances you don't need.
- Consider HSTS preload. If you're committed to HTTPS forever, submit your domain to the HSTS preload list.
- Monitor violations. Set up CSP reporting to catch issues before users do.
Run your site through the Security Headers Checker after each change to track your progress. Most sites can get from an F to a B in under 30 minutes with these configurations.