Why Security Headers Matter
HTTP security headers instruct the browser to enable (or disable) specific security behaviors. They are your first line of defense against common web attacks — XSS, clickjacking, MIME sniffing, data leaks, and protocol downgrades.
The best part: they require no code changes to your application logic. They are configured at the server or reverse proxy level and apply to every response.
Server Response
---------------
HTTP/1.1 200 OK
Content-Security-Policy: default-src 'self' <-- Blocks XSS
X-Frame-Options: DENY <-- Blocks clickjacking
X-Content-Type-Options: nosniff <-- Blocks MIME sniffing
Strict-Transport-Security: max-age=63072000 <-- Forces HTTPS
Referrer-Policy: strict-origin-when-cross-origin <-- Controls referrer
Permissions-Policy: camera=(), microphone=() <-- Restricts APIs
Content-Type: text/html; charset=utf-8
<html>...
Without security headers, browsers use permissive defaults that attackers exploit. Adding headers is one of the highest impact, lowest effort security improvements you can make.
Content-Security-Policy (CSP)
CSP is the most powerful security header. It tells the browser which sources of content (scripts, styles, images, fonts, frames, etc.) are allowed. If a source is not in the policy, the browser blocks it.
CSP is your strongest defense against XSS. Even if an attacker injects a script tag, CSP prevents it from executing if the source is not allowed.
CSP Directives
+---------------------+----------------------------------------------+
| Directive | Controls |
+---------------------+----------------------------------------------+
| default-src | Fallback for all other directives |
| script-src | JavaScript sources |
| style-src | CSS sources |
| img-src | Image sources |
| font-src | Font file sources |
| connect-src | XHR, fetch, WebSocket, EventSource targets |
| frame-src | iframe sources |
| frame-ancestors | Who can embed this page in an iframe |
| object-src | <object>, <embed>, <applet> sources |
| media-src | <audio> and <video> sources |
| base-uri | Restricts <base> element URLs |
| form-action | Where forms can submit to |
| upgrade-insecure- | Auto-upgrade HTTP to HTTPS |
| requests | |
| report-uri | URL to send violation reports to |
| report-to | Reporting API endpoint (newer) |
+---------------------+----------------------------------------------+
CSP Source Values
+---------------------+----------------------------------------------+
| Value | Meaning |
+---------------------+----------------------------------------------+
| 'self' | Same origin only |
| 'none' | Block all sources for this type |
| 'unsafe-inline' | Allow inline scripts/styles (weakens CSP) |
| 'unsafe-eval' | Allow eval() and similar (weakens CSP) |
| 'nonce-<base64>' | Allow specific inline script/style by nonce |
| 'strict-dynamic' | Trust scripts loaded by already-trusted scripts|
| https: | Any HTTPS source |
| data: | Data URIs (e.g., data:image/png) |
| *.example.com | Any subdomain of example.com |
| https://cdn.com | Specific origin |
+---------------------+----------------------------------------------+
Building a CSP: From Permissive to Strict
Start with a report-only policy, then tighten based on reports:
// Step 1: Report-only mode — learn what your app loads
const reportOnlyCSP = [
"default-src 'self'",
"script-src 'self'",
"style-src 'self'",
"img-src 'self'",
"font-src 'self'",
"connect-src 'self'",
"frame-src 'none'",
"object-src 'none'",
"report-uri /api/csp-report",
].join('; ');
app.use((req, res, next) => {
res.setHeader('Content-Security-Policy-Report-Only', reportOnlyCSP);
next();
});
// Endpoint to collect CSP violation reports
app.post('/api/csp-report', express.json({ type: 'application/csp-report' }), (req, res) => {
const report = req.body['csp-report'];
console.log('CSP Violation:', {
blockedUri: report['blocked-uri'],
violatedDirective: report['violated-directive'],
documentUri: report['document-uri'],
sourceFile: report['source-file'],
lineNumber: report['line-number'],
});
res.status(204).end();
});
// Step 2: After analyzing reports, build production CSP
const productionCSP = [
"default-src 'self'",
"script-src 'self' https://cdn.example.com",
"style-src 'self' 'unsafe-inline'", // Often needed for CSS-in-JS
"img-src 'self' data: https://images.example.com",
"font-src 'self' https://fonts.gstatic.com",
"connect-src 'self' https://api.example.com",
"frame-src 'none'",
"frame-ancestors 'none'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"upgrade-insecure-requests",
].join('; ');
app.use((req, res, next) => {
res.setHeader('Content-Security-Policy', productionCSP);
next();
});
CSP with Nonces (Recommended for Inline Scripts)
Nonces allow specific inline scripts while blocking all others. Each request gets a unique nonce.
const crypto = require('crypto');
// Middleware: generate nonce per request
app.use((req, res, next) => {
const nonce = crypto.randomBytes(16).toString('base64');
res.locals.nonce = nonce;
res.setHeader('Content-Security-Policy', [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}'`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"object-src 'none'",
"base-uri 'self'",
].join('; '));
next();
});
// In your template (EJS example):
// <script nonce="<%= nonce %>">
// // This runs because the nonce matches
// console.log('Allowed inline script');
// </script>
//
// <script>
// // This is BLOCKED — no nonce
// console.log('Blocked inline script');
// </script>
CSP in Next.js
// next.config.js
const crypto = require('crypto');
module.exports = {
async headers() {
return [
{
source: '/(.*)',
headers: [
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-eval' 'unsafe-inline'",
// Next.js requires unsafe-eval in development
// and unsafe-inline for its runtime
// Use nonce-based approach for stricter production CSP
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self'",
"frame-src 'none'",
"frame-ancestors 'none'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
].join('; '),
},
],
},
];
},
};
For strict nonce-based CSP with Next.js, use the nonce prop in next/script:
// app/layout.tsx (App Router)
import { headers } from 'next/headers';
import Script from 'next/script';
export default function RootLayout({ children }) {
const nonce = headers().get('x-nonce') || '';
return (
<html>
<body>
{children}
<Script nonce={nonce} strategy="afterInteractive">
{`console.log('This script has a nonce');`}
</Script>
</body>
</html>
);
}
X-Frame-Options (Clickjacking Prevention)
X-Frame-Options controls whether your page can be embedded in an iframe. Clickjacking attacks overlay a transparent iframe of your page on top of a malicious page — the user thinks they are clicking on the malicious page but actually clicks on your hidden page.
Clickjacking Attack:
User sees: Actually happening:
+---------------------+ +---------------------+
| Malicious page | | Your app (iframe) |
| | | (transparent) |
| "Win a prize!" | | |
| [Click Here] <---------> [Delete Account] |
| | | |
+---------------------+ +---------------------+
+---------------------+----------------------------------------------+
| Value | Effect |
+---------------------+----------------------------------------------+
| DENY | Page cannot be embedded in any iframe |
| SAMEORIGIN | Page can only be embedded by same origin |
| ALLOW-FROM uri | Page can be embedded by specified origin |
| | (deprecated — use CSP frame-ancestors) |
+---------------------+----------------------------------------------+
// Express
app.use((req, res, next) => {
res.setHeader('X-Frame-Options', 'DENY');
next();
});
// Or with helmet
const helmet = require('helmet');
app.use(helmet.frameguard({ action: 'deny' }));
Modern alternative: Use CSP frame-ancestors directive, which is more flexible:
Content-Security-Policy: frame-ancestors 'none'; /* Same as DENY */
Content-Security-Policy: frame-ancestors 'self'; /* Same as SAMEORIGIN */
Content-Security-Policy: frame-ancestors https://parent.com; /* Specific origin */
Use both headers for maximum compatibility (older browsers support X-Frame-Options but not CSP frame-ancestors).
X-Content-Type-Options (MIME Sniffing Prevention)
Browsers sometimes "sniff" the content type of a response, ignoring the Content-Type header. This can lead to security issues — an attacker uploads a file that looks like HTML/JavaScript, and the browser executes it despite the server labeling it as text/plain.
Without nosniff:
Server returns: Content-Type: text/plain
Body contains: <script>alert('xss')</script>
Browser sniffs: "This looks like HTML" --> Executes the script
With nosniff:
Server returns: Content-Type: text/plain
X-Content-Type-Options: nosniff
Browser: "Server said text/plain, I will treat it as text/plain"
This header has exactly one valid value:
X-Content-Type-Options: nosniff
// Express
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
next();
});
// With helmet (enabled by default)
app.use(helmet());
Always set the correct Content-Type on your responses. This header is a safety net, not a replacement for proper content typing.
Strict-Transport-Security (HSTS)
HSTS forces browsers to use HTTPS for your domain. Once set, the browser will never make an HTTP request to your domain — it converts all HTTP URLs to HTTPS automatically.
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
+---------------------+----------------------------------------------+
| Directive | Purpose |
+---------------------+----------------------------------------------+
| max-age | Duration (seconds) to remember HTTPS-only |
| | 63072000 = 2 years |
| includeSubDomains | Apply to all subdomains |
| preload | Eligible for browser preload list |
+---------------------+----------------------------------------------+
// Express
app.use((req, res, next) => {
res.setHeader(
'Strict-Transport-Security',
'max-age=63072000; includeSubDomains; preload'
);
next();
});
// With helmet
app.use(helmet.hsts({
maxAge: 63072000,
includeSubDomains: true,
preload: true,
}));
Important considerations:
- Only set HSTS after confirming HTTPS works on ALL pages and subdomains
- Start with a short
max-age(e.g., 300 = 5 minutes) and increase gradually includeSubDomainsmeans ALL subdomains must support HTTPSpreloadis practically permanent — your domain is hardcoded into browsers
// Gradual HSTS rollout
// Week 1: Test with 5 minutes
'Strict-Transport-Security: max-age=300'
// Week 2: Increase to 1 day
'Strict-Transport-Security: max-age=86400'
// Week 3: Increase to 1 week
'Strict-Transport-Security: max-age=604800'
// Week 4: Add includeSubDomains
'Strict-Transport-Security: max-age=604800; includeSubDomains'
// Month 2: Full production value
'Strict-Transport-Security: max-age=63072000; includeSubDomains; preload'
Referrer-Policy
The Referer header (yes, the misspelling is intentional — it is in the HTTP spec) tells the destination site where the user came from. This can leak sensitive URLs (with tokens, query parameters, or private paths).
User on: https://app.com/dashboard?token=secret123
Clicks link to: https://external.com
Without Referrer-Policy:
Referer: https://app.com/dashboard?token=secret123
(External site sees the token!)
With strict-origin-when-cross-origin:
Referer: https://app.com
(Only the origin, no path or query)
Referrer-Policy Values
+------------------------------------------+----------------------------------------------+
| Policy | What Is Sent |
+------------------------------------------+----------------------------------------------+
| no-referrer | Nothing (safest, may break analytics) |
| no-referrer-when-downgrade | Full URL for HTTPS->HTTPS, nothing for |
| | HTTPS->HTTP |
| origin | Only origin (https://app.com) |
| origin-when-cross-origin | Full URL for same-origin, origin for cross |
| same-origin | Full URL for same-origin, nothing for cross |
| strict-origin | Origin for HTTPS->HTTPS, nothing for downgrade|
| strict-origin-when-cross-origin | Full URL same-origin, origin cross-origin, |
| | nothing for downgrade (RECOMMENDED) |
| unsafe-url | Full URL always (never use) |
+------------------------------------------+----------------------------------------------+
// Express — recommended policy
app.use((req, res, next) => {
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
next();
});
// With helmet
app.use(helmet.referrerPolicy({
policy: 'strict-origin-when-cross-origin',
}));
You can also set it per-element in HTML:
<!-- Override for specific links -->
<a href="https://external.com" referrerpolicy="no-referrer">External Link</a>
<!-- Override for specific images -->
<img src="https://analytics.com/pixel.gif" referrerpolicy="no-referrer" />
Permissions-Policy (formerly Feature-Policy)
Permissions-Policy controls which browser features and APIs your page can use. It can disable features entirely or restrict them to specific origins.
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
This disables camera, microphone, geolocation, and payment APIs on your page. Even if XSS occurs, the attacker cannot use these APIs.
Common Directives
+---------------------+----------------------------------------------+
| Feature | Controls |
+---------------------+----------------------------------------------+
| camera | Camera access (getUserMedia video) |
| microphone | Microphone access (getUserMedia audio) |
| geolocation | Location access |
| payment | Payment Request API |
| usb | WebUSB API |
| accelerometer | Accelerometer sensor |
| gyroscope | Gyroscope sensor |
| magnetometer | Magnetometer sensor |
| fullscreen | Fullscreen API |
| autoplay | Media autoplay |
| picture-in-picture | Picture-in-Picture API |
| display-capture | Screen capture (getDisplayMedia) |
+---------------------+----------------------------------------------+
Permissions-Policy Values
+---------------------+----------------------------------------------+
| Value | Meaning |
+---------------------+----------------------------------------------+
| () | Disabled for all contexts |
| (self) | Allowed for this origin only |
| (self "https://x") | Allowed for this origin and x |
| * | Allowed for all origins |
+---------------------+----------------------------------------------+
// Express
app.use((req, res, next) => {
res.setHeader('Permissions-Policy', [
'camera=()',
'microphone=()',
'geolocation=()',
'payment=()',
'usb=()',
'accelerometer=()',
'gyroscope=()',
'magnetometer=()',
'display-capture=()',
].join(', '));
next();
});
If your app needs some of these features, allow only for your origin:
res.setHeader('Permissions-Policy', [
'camera=(self)', // Only your origin can use camera
'microphone=(self)', // Only your origin can use microphone
'geolocation=()', // Nobody can use geolocation
'payment=(self "https://pay.example.com")', // Your origin + payment provider
].join(', '));
Implementing All Headers Together
Express with Helmet
const express = require('express');
const helmet = require('helmet');
const app = express();
// Helmet sets many security headers by default
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
connectSrc: ["'self'", 'https://api.example.com'],
frameSrc: ["'none'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
frameAncestors: ["'none'"],
upgradeInsecureRequests: [],
},
},
crossOriginEmbedderPolicy: true,
crossOriginOpenerPolicy: true,
crossOriginResourcePolicy: { policy: 'same-origin' },
hsts: {
maxAge: 63072000,
includeSubDomains: true,
preload: true,
},
referrerPolicy: {
policy: 'strict-origin-when-cross-origin',
},
}));
// Permissions-Policy (helmet doesn't set this by default)
app.use((req, res, next) => {
res.setHeader('Permissions-Policy', [
'camera=()',
'microphone=()',
'geolocation=()',
'payment=()',
].join(', '));
next();
});
Next.js Configuration
// next.config.js
const securityHeaders = [
{
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'",
"connect-src 'self'",
"frame-src 'none'",
"frame-ancestors 'none'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"upgrade-insecure-requests",
].join('; '),
},
{
key: 'X-Frame-Options',
value: 'DENY',
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=(), payment=()',
},
{
key: 'X-DNS-Prefetch-Control',
value: 'on',
},
];
module.exports = {
async headers() {
return [
{
source: '/(.*)',
headers: securityHeaders,
},
];
},
// Also remove X-Powered-By
poweredByHeader: false,
};
Nginx Configuration
server {
listen 443 ssl http2;
server_name example.com;
# TLS
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# Security Headers
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-src 'none'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests;" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
# Hide server version
server_tokens off;
location / {
proxy_pass http://localhost:3000;
proxy_hide_header X-Powered-By;
}
}
Testing Security Headers
Using curl
# Check all response headers
curl -I https://example.com
# Check specific header
curl -s -D- https://example.com | grep -i "content-security-policy"
curl -s -D- https://example.com | grep -i "strict-transport-security"
curl -s -D- https://example.com | grep -i "x-frame-options"
Programmatic Header Check
async function auditSecurityHeaders(url) {
const response = await fetch(url, { method: 'HEAD' });
const headers = response.headers;
const checks = [
{
name: 'Content-Security-Policy',
header: headers.get('content-security-policy'),
required: true,
},
{
name: 'X-Frame-Options',
header: headers.get('x-frame-options'),
required: true,
expected: ['DENY', 'SAMEORIGIN'],
},
{
name: 'X-Content-Type-Options',
header: headers.get('x-content-type-options'),
required: true,
expected: ['nosniff'],
},
{
name: 'Strict-Transport-Security',
header: headers.get('strict-transport-security'),
required: true,
},
{
name: 'Referrer-Policy',
header: headers.get('referrer-policy'),
required: true,
},
{
name: 'Permissions-Policy',
header: headers.get('permissions-policy'),
required: true,
},
{
name: 'X-Powered-By',
header: headers.get('x-powered-by'),
required: false,
shouldBeAbsent: true,
},
{
name: 'Server',
header: headers.get('server'),
required: false,
warn: 'Consider hiding server version',
},
];
const results = checks.map(check => {
if (check.shouldBeAbsent) {
return {
name: check.name,
status: check.header ? 'FAIL' : 'PASS',
detail: check.header ? `Present: ${check.header} (should be removed)` : 'Absent (good)',
};
}
if (check.required && !check.header) {
return {
name: check.name,
status: 'FAIL',
detail: 'Missing',
};
}
if (check.expected && check.header) {
const valid = check.expected.includes(check.header);
return {
name: check.name,
status: valid ? 'PASS' : 'WARN',
detail: check.header,
};
}
return {
name: check.name,
status: check.header ? 'PASS' : 'SKIP',
detail: check.header || check.warn || 'Not set',
};
});
return results;
}
// Usage
auditSecurityHeaders('https://example.com').then(results => {
results.forEach(r => {
const icon = r.status === 'PASS' ? '[OK]' : r.status === 'FAIL' ? '[!!]' : '[??]';
console.log(`${icon} ${r.name}: ${r.detail}`);
});
});
Integration Test
// tests/security-headers.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Security Headers', () => {
test('all required security headers are present', async ({ request }) => {
const response = await request.get('/');
// CSP
expect(response.headers()['content-security-policy']).toBeDefined();
expect(response.headers()['content-security-policy']).toContain("default-src");
// Clickjacking
expect(response.headers()['x-frame-options']).toBe('DENY');
// MIME sniffing
expect(response.headers()['x-content-type-options']).toBe('nosniff');
// HSTS
const hsts = response.headers()['strict-transport-security'];
expect(hsts).toBeDefined();
expect(hsts).toContain('max-age=');
// Referrer
expect(response.headers()['referrer-policy']).toBeDefined();
// Permissions
expect(response.headers()['permissions-policy']).toBeDefined();
// No server info leak
expect(response.headers()['x-powered-by']).toBeUndefined();
});
test('CSP blocks inline scripts', async ({ page }) => {
let cspViolation = false;
page.on('console', msg => {
if (msg.text().includes('CSP')) cspViolation = true;
});
await page.goto('/');
// Try to inject an inline script
await page.evaluate(() => {
const script = document.createElement('script');
script.textContent = 'window.__xss = true';
document.body.appendChild(script);
});
// If CSP is working, the script should not execute
const xssResult = await page.evaluate(() => (window as any).__xss);
expect(xssResult).toBeUndefined();
});
});
Security Headers Quick Reference
+-----------------------------+-----------------------------------------+----------+
| Header | Recommended Value | Priority |
+-----------------------------+-----------------------------------------+----------+
| Content-Security-Policy | default-src 'self'; script-src 'self'; | Critical |
| | object-src 'none'; base-uri 'self' | |
+-----------------------------+-----------------------------------------+----------+
| Strict-Transport-Security | max-age=63072000; includeSubDomains; | Critical |
| | preload | |
+-----------------------------+-----------------------------------------+----------+
| X-Content-Type-Options | nosniff | Critical |
+-----------------------------+-----------------------------------------+----------+
| X-Frame-Options | DENY | High |
+-----------------------------+-----------------------------------------+----------+
| Referrer-Policy | strict-origin-when-cross-origin | High |
+-----------------------------+-----------------------------------------+----------+
| Permissions-Policy | camera=(), microphone=(), | Medium |
| | geolocation=() | |
+-----------------------------+-----------------------------------------+----------+
| X-Powered-By | (remove this header) | Medium |
+-----------------------------+-----------------------------------------+----------+
| Cross-Origin-Opener-Policy | same-origin | Medium |
+-----------------------------+-----------------------------------------+----------+
| Cross-Origin-Embedder- | require-corp | Medium |
| Policy | | |
+-----------------------------+-----------------------------------------+----------+
| Cross-Origin-Resource- | same-origin | Medium |
| Policy | | |
+-----------------------------+-----------------------------------------+----------+
Security Headers Checklist
+------+----------------------------------------------+----------+
| # | Check | Status |
+------+----------------------------------------------+----------+
| 1 | CSP deployed (at minimum: default-src 'self') | [ ] |
| 2 | CSP report-uri configured for monitoring | [ ] |
| 3 | X-Frame-Options: DENY (or SAMEORIGIN) | [ ] |
| 4 | CSP frame-ancestors set (modern replacement) | [ ] |
| 5 | X-Content-Type-Options: nosniff | [ ] |
| 6 | HSTS enabled with long max-age | [ ] |
| 7 | HSTS includeSubDomains set | [ ] |
| 8 | Referrer-Policy set | [ ] |
| 9 | Permissions-Policy disables unused APIs | [ ] |
| 10 | X-Powered-By header removed | [ ] |
| 11 | Server header minimized or removed | [ ] |
| 12 | Headers tested with securityheaders.com | [ ] |
| 13 | Headers tested with automated tests | [ ] |
| 14 | Headers applied to all responses (not just HTML)| [ ] |
+------+----------------------------------------------+----------+
Summary
Security headers are low-effort, high-impact defenses that instruct browsers to protect your users. Every web application should implement them.
Priority order:
- Content-Security-Policy — Strongest defense against XSS. Start with report-only, then enforce.
- Strict-Transport-Security — Prevents HTTPS downgrade attacks. Non-negotiable.
- X-Content-Type-Options — Prevents MIME sniffing. One line, zero downside.
- X-Frame-Options / frame-ancestors — Prevents clickjacking. Use both for compatibility.
- Referrer-Policy — Prevents URL leaks to third parties.
- Permissions-Policy — Disables browser APIs you do not use.
Implementation strategy:
- Start with report-only CSP, deploy other headers immediately
- Analyze CSP reports, refine policy
- Enforce CSP when ready
- Test headers in CI pipeline
- Monitor for violations continuously
Security headers are not a replacement for secure code — they are an additional layer. Combine them with input validation, output encoding, authentication, and all other security practices in this series.