Production-ready HTTP security headers for Apache, Nginx, Node.js, and more.
License: MIT Security Rating: A+
Security headers are your first line of defense against:
- XSS (Cross-Site Scripting) — malicious scripts injected into your site
- Clickjacking — invisible frames tricking users into clicking malicious content
- Data leaks — unauthorized access to cross-origin resources
- MIME-type attacks — browsers executing malicious content
- Referrer leaks — sensitive URL data exposed to third parties
- Base tag injection — attackers manipulating relative URLs
- FLoC/Topics tracking — privacy-invasive browser features
Result: Setting proper headers can boost your security rating from F to A+ in minutes, with zero code changes to your application.
curl -I https://yoursite.com | grep -i "x-frame\|content-security\|strict-transport" # Or use online tools: # https://securityheaders.com # https://observatory.mozilla.org
Add this to your .htaccess or Apache virtual host config:
# ============================================ # Security Headers — Production Config (2025) # ============================================ # Hide PHP version (if using PHP) php_flag expose_php off <IfModule mod_headers.c> # Isolation headers Header always set Cross-Origin-Opener-Policy "same-origin" Header always set Cross-Origin-Resource-Policy "same-origin" Header always set Cross-Origin-Embedder-Policy "require-corp" # Content Security Policy # OPTION 1: Strict (recommended for new sites) Header always set Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; object-src 'none'; upgrade-insecure-requests" # OPTION 2: Relaxed (for sites with inline scripts/styles) # Header always set Content-Security-Policy "default-src 'self' https: data: 'unsafe-inline'; connect-src 'self'; base-uri 'self'; object-src 'none'" # Frame protection Header always set X-Frame-Options "SAMEORIGIN" # MIME-type protection Header always set X-Content-Type-Options "nosniff" # Download handling (Legacy IE8 - optional in 2025) # Header always set X-Download-Options "noopen" # Feature policies (2025 updated) # Note: browsing-topics blocks Google's Topics API (FLoC successor) Header always set Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), browsing-topics=(), interest-cohort=()" # Referrer policy Header always set Referrer-Policy "strict-origin-when-cross-origin" # HTTPS enforcement (only if you have SSL!) # WARNING: Only enable if your ENTIRE site uses HTTPS permanently # Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" # Adobe policy restrictions Header always set X-Permitted-Cross-Domain-Policies "none" </IfModule> # ============================================ # Additional Security Measures # ============================================ # Disable directory browsing Options -Indexes # Disable server signature ServerSignature Off # Block suspicious request methods <LimitExcept GET POST HEAD> deny from all </LimitExcept>
Header always vs Header set:
Header set→ Only applied to successful responses (2xx)Header always→ Applied to ALL responses including errors (3xx/4xx/5xx)- Always use
alwaysfor security headers to ensure protection even on error pages
# Enable mod_headers
sudo a2enmod headers
sudo systemctl restart apache2Add to your nginx.conf or site-specific config in /etc/nginx/sites-available/:
server { listen 443 ssl http2; server_name yoursite.com; # Hide Nginx version server_tokens off; # Security headers add_header Cross-Origin-Opener-Policy "same-origin" always; add_header Cross-Origin-Resource-Policy "same-origin" always; add_header Cross-Origin-Embedder-Policy "require-corp" always; # Content Security Policy (2025) # Note: 'https:' allows loading from ANY https source - restrict to specific domains in production add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; object-src 'none'; upgrade-insecure-requests" always; # Frame protection add_header X-Frame-Options "SAMEORIGIN" always; # MIME-type protection add_header X-Content-Type-Options "nosniff" always; # Referrer policy add_header Referrer-Policy "strict-origin-when-cross-origin" always; # HTTPS enforcement add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; # Permissions policy (2025) # browsing-topics blocks Google's Topics API (FLoC successor for ad targeting) add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=(), browsing-topics=(), interest-cohort=()" always; # Adobe policies add_header X-Permitted-Cross-Domain-Policies "none" always; # Your site config continues here... root /var/www/html; index index.html; }
# Test configuration sudo nginx -t # Reload if successful sudo systemctl reload nginx
const express = require('express'); const helmet = require('helmet'); const crypto = require('crypto'); const app = express(); // Apply all security headers with 2025 best practices app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'"], styleSrc: ["'self'"], imgSrc: ["'self'", "data:", "https:"], connectSrc: ["'self'"], fontSrc: ["'self'"], objectSrc: ["'none'"], mediaSrc: ["'self'"], frameSrc: ["'none'"], baseUri: ["'self'"], formAction: ["'self'"], upgradeInsecureRequests: [], }, }, crossOriginEmbedderPolicy: true, crossOriginOpenerPolicy: { policy: "same-origin" }, crossOriginResourcePolicy: { policy: "same-origin" }, hsts: { maxAge: 31536000, includeSubDomains: true, preload: true }, referrerPolicy: { policy: "strict-origin-when-cross-origin" } })); // Additional Permissions-Policy header (Helmet doesn't cover all 2025 features yet) app.use((req, res, next) => { res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=(), payment=(), usb=(), browsing-topics=(), interest-cohort=()'); next(); }); app.get('/', (req, res) => { res.send('Secured with Helmet!'); }); app.listen(3000);
const crypto = require('crypto'); // Middleware to generate nonce per request app.use((req, res, next) => { res.locals.nonce = crypto.randomBytes(16).toString('base64'); next(); }); app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`], styleSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`], objectSrc: ["'none'"], baseUri: ["'self'"], }, }, })); // Use nonce in your templates app.get('/', (req, res) => { res.send(` <!DOCTYPE html> <html> <head> <script nonce="${res.locals.nonce}"> console.log('Inline script allowed with nonce!'); </script> </head> <body>Nonce-based CSP</body> </html> `); });
app.use((req, res, next) => { res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); res.setHeader('Cross-Origin-Resource-Policy', 'same-origin'); res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp'); res.setHeader('Content-Security-Policy', "default-src 'self'; object-src 'none'; base-uri 'self'"); res.setHeader('X-Frame-Options', 'SAMEORIGIN'); res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=(), browsing-topics=(), interest-cohort=()'); next(); });
Create nginx-security.conf:
# Include this in your Nginx Docker image add_header Cross-Origin-Opener-Policy "same-origin" always; add_header Cross-Origin-Resource-Policy "same-origin" always; add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'self'" 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 "interest-cohort=(), browsing-topics=()" always;
Dockerfile:
FROM nginx:alpine COPY nginx-security.conf /etc/nginx/conf.d/security.conf COPY nginx.conf /etc/nginx/nginx.conf EXPOSE 80 443
Add headers via Workers or Transform Rules:
addEventListener('fetch', event => { event.respondWith(handleRequest(event.request)) }) async function handleRequest(request) { const response = await fetch(request) // Clone response to modify headers const newResponse = new Response(response.body, response) // Add security headers (2025) newResponse.headers.set('Cross-Origin-Opener-Policy', 'same-origin') newResponse.headers.set('Cross-Origin-Resource-Policy', 'same-origin') newResponse.headers.set('Content-Security-Policy', "default-src 'self'; object-src 'none'; base-uri 'self'") newResponse.headers.set('X-Frame-Options', 'SAMEORIGIN') newResponse.headers.set('X-Content-Type-Options', 'nosniff') newResponse.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin') newResponse.headers.set('Permissions-Policy', 'browsing-topics=(), interest-cohort=()') return newResponse }
WordPress often requires relaxed CSP due to:
- Inline scripts in themes/plugins
- Third-party assets (fonts, analytics, CDNs)
- Admin panel dynamic content
<IfModule mod_headers.c> # Isolation headers (safe for WordPress) Header always set Cross-Origin-Opener-Policy "same-origin-allow-popups" Header always set Cross-Origin-Resource-Policy "cross-origin" # Relaxed CSP for WordPress (2025) Header always set Content-Security-Policy "default-src 'self' https: data: 'unsafe-inline' 'unsafe-eval'; img-src 'self' https: data:; font-src 'self' https: data:; connect-src 'self' https:; base-uri 'self'; object-src 'none'" # Frame protection (allows same-origin for admin) Header always set X-Frame-Options "SAMEORIGIN" # MIME protection Header always set X-Content-Type-Options "nosniff" # Referrer policy Header always set Referrer-Policy "strict-origin-when-cross-origin" # Permissions policy Header always set Permissions-Policy "geolocation=(), microphone=(), camera=(), browsing-topics=(), interest-cohort=()" # HSTS (only if SSL is configured) # Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains" </IfModule>
If .htaccess doesn't work, use this in functions.php:
// Add security headers via PHP function add_security_headers() { header('Cross-Origin-Opener-Policy: same-origin-allow-popups'); header('Cross-Origin-Resource-Policy: cross-origin'); header('X-Frame-Options: SAMEORIGIN'); header('X-Content-Type-Options: nosniff'); header('Referrer-Policy: strict-origin-when-cross-origin'); header('Permissions-Policy: geolocation=(), microphone=(), camera=(), browsing-topics=(), interest-cohort=()'); header("Content-Security-Policy: default-src 'self' https: data: 'unsafe-inline' 'unsafe-eval'; base-uri 'self'; object-src 'none'"); } add_action('send_headers', 'add_security_headers');
Purpose: Controls which resources can be loaded
Strict (2025 recommended):
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; object-src 'none'; base-uri 'self'; upgrade-insecure-requests
Key 2025 additions:
object-src 'none'— Blocks Flash and legacy pluginsbase-uri 'self'— Prevents base tag injection attacksupgrade-insecure-requests— Auto-upgrades HTTP to HTTPS
Relaxed (for legacy sites):
Content-Security-Policy: default-src 'self' https: data: 'unsafe-inline'; base-uri 'self'; object-src 'none'
Common CSP issues:
- Inline scripts blocked → Use nonces or move to external
.jsfiles - Google Analytics blocked → Add
script-src 'self' https://www.google-analytics.com - Fonts not loading → Add
font-src 'self' https://fonts.gstatic.com
CSP with Nonces (best practice):
<!-- Server generates nonce per request --> <script nonce="2726c7f26c"> console.log('Allowed!'); </script>
Subresource Integrity (SRI) for CDNs:
<script src="https://cdn.example.com/lib.js" integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/ux..." crossorigin="anonymous"></script>
Purpose: Forces HTTPS for all connections
Configuration:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
- Only enable if your entire site uses SSL permanently
- Once set, browsers will refuse HTTP for the specified duration
preloaddirective submits your domain to browser HSTS lists- Preload is IRREVERSIBLE — Removal takes months and requires support tickets
Preload Submission Process:
- Ensure HTTPS works on all subdomains
- Set
max-age=31536000minimum - Submit at https://hstspreload.org
- Wait for inclusion in Chromium/Firefox/Safari lists
- Cannot easily undo — Only submit if 100% certain
Safe rollout strategy:
# Week 1: Short max-age for testing Header always set Strict-Transport-Security "max-age=300" # Week 2-4: Increase gradually Header always set Strict-Transport-Security "max-age=86400" # Month 2+: Full deployment Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains" # Only after 6+ months of stability: Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
Purpose: Prevents clickjacking attacks
Options:
DENY— Never allow framingSAMEORIGIN— Allow framing from same domain only(削除)— (deprecated, use CSPALLOW-FROM(削除ここまで)frame-ancestorsinstead)
Modern alternative: Use CSP frame-ancestors directive:
Content-Security-Policy: frame-ancestors 'self'
X-Content-Type-Options: nosniff
Prevents browsers from MIME-sniffing responses away from declared content-type.
Why it matters: Without this, browsers might execute image.jpg as JavaScript if it contains code.
Options:
no-referrer— Never send referrer (breaks some analytics)strict-origin-when-cross-origin— (recommended) Full URL for same-origin, origin only for cross-originsame-origin— Only send for same-origin requests
Privacy consideration: Balance between analytics needs and user privacy.
2025/2026 updated syntax:
Permissions-Policy: geolocation=(), microphone=(), camera=(), payment=(), usb=(), browsing-topics=(), interest-cohort=()
New in 2025+:
browsing-topics=()— Blocks Google Topics API (FLoC successor for ad targeting)interest-cohort=()— Blocks legacy FLoC (kept for older browser compatibility)
Common permissions:
geolocation— GPS locationmicrophone— Audio inputcamera— Video inputpayment— Payment Request APIusb— USB device accessmagnetometer,gyroscope— Motion sensors
Purpose: Isolates browsing context from cross-origin documents
Options:
same-origin— Strictest, breaks pop-upssame-origin-allow-popups— Allows pop-ups (better for WordPress)
Purpose: Prevents resources from being loaded by other origins
Options:
same-origin— Only same domainsame-site— Same site (includes subdomains)cross-origin— Allow all (least secure)
Purpose: Required for advanced features like SharedArrayBuffer
Configuration:
Cross-Origin-Embedder-Policy: require-corp
# WRONG — This header is deprecated and harmful
X-XSS-Protection: 1; mode=block
Why deprecated:
- Chrome removed support in 2019
- Safari implementation has security bugs
- Can introduce vulnerabilities instead of preventing them
- Superseded by Content-Security-Policy
Correct approach: Rely on CSP instead. If you must set it, disable it:
X-XSS-Protection: 0
-
Security Headers — https://securityheaders.com
Quick grade (A+ to F) with actionable recommendations -
Mozilla Observatory — https://observatory.mozilla.org
Comprehensive scan with detailed explanations -
CSP Evaluator — https://csp-evaluator.withgoogle.com
Validates Content-Security-Policy syntax -
HSTS Preload — https://hstspreload.org
Check preload eligibility and status
# Check all security headers curl -I https://yoursite.com | grep -iE "content-security|x-frame|strict-transport|x-content|referrer" # Test specific header curl -I https://yoursite.com | grep -i "x-frame-options" # Check from different location (for CDN testing) curl -H "Host: yoursite.com" -I https://cdn-ip-address/ # Verify CSP is applied curl -I https://yoursite.com | grep -i "content-security-policy"
- Open DevTools (F12)
- Go to Network tab
- Refresh page
- Click on main document
- Check Response Headers section
- Look for CSP violations in Console tab
Setup CSP reporting:
# Apache Header always set Content-Security-Policy "default-src 'self'; report-uri https://yoursite.com/csp-report" # Or use report-only mode during testing Header always set Content-Security-Policy-Report-Only "default-src 'self'; report-uri https://yoursite.com/csp-report"
Node.js CSP report endpoint:
app.post('/csp-report', express.json({ type: 'application/csp-report' }), (req, res) => { console.log('CSP Violation:', req.body); res.status(204).end(); });
Symptom: Site appears broken, console shows CSP violations
Solution: Start with a relaxed policy, then tighten:
# Phase 1: Report-only mode (doesn't block, only logs) Header set Content-Security-Policy-Report-Only "default-src 'self'; report-uri /csp-report" # Phase 2: After reviewing violations, enable blocking with relaxed policy Header set Content-Security-Policy "default-src 'self' https: data: 'unsafe-inline'; base-uri 'self'" # Phase 3: Tighten after confirming no issues Header set Content-Security-Policy "default-src 'self'; script-src 'self'; base-uri 'self'; object-src 'none'"
Symptom: Users can't access site even after fixing SSL
Solution:
- Renew SSL immediately
- Use short
max-ageinitially:max-age=300(5 minutes) - Gradually increase after confirming stability
- Never use
preloaduntil 100% certain
Symptom: Can't save posts, plugins fail to update
Solution: Use WordPress-specific config (see WordPress section)
Symptom: YouTube videos, Google Maps, etc. blocked
Solution: Adjust CSP frame-src and connect-src:
Header set Content-Security-Policy "default-src 'self'; frame-src 'self' https://www.youtube.com https://www.google.com; connect-src 'self' https://www.google-analytics.com"
Solution: Add to CSP:
Header set Content-Security-Policy "default-src 'self'; script-src 'self' https://www.googletagmanager.com https://www.google-analytics.com; connect-src 'self' https://www.google-analytics.com"
Solution: Add font-src and style-src:
Header set Content-Security-Policy "default-src 'self'; font-src 'self' https://fonts.gstatic.com; style-src 'self' https://fonts.googleapis.com"
Begin with relaxed policies, monitor for issues, then tighten gradually.
Never deploy security headers directly to production without testing.
Implement CSP reporting to catch issues before users do.
Keep .htaccess / nginx.conf in Git to track changes.
If you must use 'unsafe-inline', document WHY in comments:
# 'unsafe-inline' required for: # - WordPress admin dashboard (uses inline scripts) # - Theme customizer (inline styles) # TODO: Migrate to nonce-based CSP when possible Header set Content-Security-Policy "default-src 'self' 'unsafe-inline'"
Re-scan with securityheaders.com monthly to catch regressions.
Nonces allow specific inline scripts without blanket permission:
// Generate per request const nonce = crypto.randomBytes(16).toString('base64'); res.setHeader('Content-Security-Policy', `script-src 'nonce-${nonce}'`);
For all third-party scripts:
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.7.0" integrity="sha384-..." crossorigin="anonymous"></script>
<IfModule mod_headers.c> Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains" Header always set Content-Security-Policy "default-src 'self' https: data: 'unsafe-inline' 'unsafe-eval'; base-uri 'self'; object-src 'none'" 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 "interest-cohort=(), browsing-topics=()" </IfModule>
# Cloudflare already provides some headers, avoid duplication add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'self'" always; add_header X-Frame-Options "DENY" always; add_header X-Content-Type-Options "nosniff" always; add_header Permissions-Policy "interest-cohort=()" always;
app.use(helmet({ contentSecurityPolicy: false, // Not needed for APIs frameguard: { action: 'deny' }, hsts: { maxAge: 31536000 }, noSniff: true }));
Monitor network-level errors:
# Apache Header always set NEL '{"report_to":"default","max_age":31536000,"include_subdomains":true}' Header always set Report-To '{"group":"default","max_age":31536000,"endpoints":[{"url":"https://yoursite.com/nel-report"}],"include_subdomains":true}'
// Node.js endpoint app.post('/nel-report', express.json(), (req, res) => { console.log('Network Error:', req.body); res.status(204).end(); });
Note: Deprecated as of June 2021 (CT now mandatory), but still useful for older browsers:
Header always set Expect-CT "max-age=86400, enforce, report-uri='https://yoursite.com/ct-report'"
Improvements welcome!
- Fork repository
- Add your platform-specific config
- Test thoroughly
- Submit PR with documentation
MIT License — Use freely, modify as needed, no warranty provided.
Found this useful?
- ⭐ Star this repository
- 🐛 Report issues
- 💡 Suggest improvements
- 💖 Sponsor development
Stay secure. Stay paranoid. 🔒
- Security Headers — Complete Implementation Guide
- Securing FastAPI Applications
- ModSecurity Webserver Protection Guide
- GPT Security Best Practices
updated 11.12.2025