Security Headers: Your Last Line of Defense
Security headers don't prevent vulnerabilities in your code — they limit what attackers can do when vulnerabilities exist. They're the browser-level safety net that catches attacks that slip through your application defenses.
The good news: most security headers are one-liners to implement. The challenge is understanding what each header does, which value is right for your application, and how to test that they're actually working.
Content-Security-Policy (CSP)
The most powerful and complex security header. CSP defines a whitelist of sources for every type of resource the browser can load:
Content-Security-Policy:
default-src 'self';
script-src 'self' https://cdn.example.com 'nonce-{random}';
style-src 'self' https://fonts.googleapis.com 'unsafe-inline';
font-src 'self' https://fonts.gstatic.com;
img-src 'self' data: https:;
connect-src 'self' https://api.example.com wss://ws.example.com;
media-src 'none';
object-src 'none';
frame-src https://youtube.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
upgrade-insecure-requests;
report-uri https://example.com/csp-reports;
Key directives explained:
| Directive | What it controls |
|---|---|
default-src |
Fallback for all unspecified resource types |
script-src |
JavaScript sources (most important!) |
style-src |
CSS sources |
img-src |
Image sources |
connect-src |
fetch, XHR, WebSocket destinations |
frame-ancestors |
Who can embed this page (replaces X-Frame-Options) |
base-uri |
Restricts <base> tag (prevents base tag injection) |
form-action |
Where forms can submit |
upgrade-insecure-requests |
Upgrades HTTP subresources to HTTPS |
Start with report-only mode:
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-reports
This reports violations without blocking anything — essential for testing CSP before deployment.
CSP Nonces (Better than unsafe-inline)
// Express middleware generating fresh nonce per request
import crypto from 'crypto'
export function cspMiddleware(req: Request, res: Response, next: NextFunction) {
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' 'nonce-${nonce}'`,
"img-src 'self' data: https:",
"object-src 'none'",
"base-uri 'self'",
"frame-ancestors 'none'",
].join('; '))
next()
}
// In template:
// <script nonce="<%= nonce %>">...</script>
HTTP Strict Transport Security (HSTS)
Forces browsers to always use HTTPS for your domain:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
max-age=31536000: Browser remembers HTTPS-only for 1 yearincludeSubDomains: Applies to all subdomainspreload: Allows submission to browser preload lists (hardcoded HTTPS)
Deployment strategy — start conservative, increase over time:
# Phase 1: Test with short max-age
Strict-Transport-Security: max-age=300
# Phase 2: Increase to 1 day
Strict-Transport-Security: max-age=86400
# Phase 3: Add subdomains (only if ALL subdomains support HTTPS)
Strict-Transport-Security: max-age=86400; includeSubDomains
# Phase 4: Full year (submit to preload list)
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Warning: HSTS with preload is difficult to undo. If you can't serve HTTPS from a subdomain, don't use includeSubDomains.
CORS (Cross-Origin Resource Sharing)
CORS controls which domains can make requests to your API from browsers:
// Express CORS middleware
import cors from 'cors'
// ❌ Too permissive for production APIs:
app.use(cors()) // Allows ALL origins
// ✅ Explicit allowlist:
const allowedOrigins = [
'https://app.example.com',
'https://admin.example.com',
process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : null,
].filter(Boolean) as string[]
app.use(cors({
origin: (origin, callback) => {
// Allow requests with no origin (curl, Postman, server-to-server)
if (!origin) return callback(null, true)
if (allowedOrigins.includes(origin)) {
callback(null, true)
} else {
callback(new Error(`CORS blocked: ${origin} not allowed`))
}
},
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-CSRF-Token'],
credentials: true, // Allow cookies
maxAge: 86400, // Cache preflight for 24 hours
}))
Understanding preflight requests:
For "complex" requests (non-simple methods, custom headers):
Browser sends OPTIONS request first:
OPTIONS /api/users HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
Server responds:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST, GET, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
Then browser sends the actual request.
X-Frame-Options vs frame-ancestors
# Old way — still needed for IE11:
X-Frame-Options: DENY
# or:
X-Frame-Options: SAMEORIGIN
# Modern way (CSP frame-ancestors):
Content-Security-Policy: frame-ancestors 'none'
# or:
Content-Security-Policy: frame-ancestors 'self' https://trusted-dashboard.example.com
Use both for maximum browser compatibility:
X-Frame-Options: DENY
Content-Security-Policy: frame-ancestors 'none'
Permissions-Policy
Controls browser features for your page and iframes:
Permissions-Policy:
camera=(),
microphone=(),
geolocation=(self),
payment=(self "https://payment.example.com"),
usb=(),
accelerometer=(),
gyroscope=()
camera=(): Disable entirely (empty = deny all)geolocation=(self): Allow for this origin onlypayment=(self "https://..."): Allow for this origin + specific third party
Additional Security Headers
# Prevent MIME type sniffing
X-Content-Type-Options: nosniff
# Referrer information control
Referrer-Policy: strict-origin-when-cross-origin
# strict-origin-when-cross-origin: Send origin only for cross-origin HTTPS→HTTPS,
# full URL for same-origin, nothing for HTTPS→HTTP
# Cross-Origin policies (advanced)
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-origin
# These three together enable SharedArrayBuffer and high-resolution timers
# Required for: WebAssembly threads, Atomics, performance.measureUserAgentSpecificMemory()
Complete Header Configuration
Express.js with Helmet:
import helmet from 'helmet'
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://api.example.com"],
frameSrc: ["'none'"],
objectSrc: ["'none'"],
upgradeInsecureRequests: [],
},
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
},
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
permittedCrossDomainPolicies: false,
crossOriginEmbedderPolicy: false, // Only enable if needed
}))
Next.js (next.config.js):
const securityHeaders = [
{ key: 'X-DNS-Prefetch-Control', value: 'on' },
{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains; preload' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(self)' },
]
module.exports = {
async headers() {
return [
{
source: '/:path*',
headers: securityHeaders,
},
]
},
}
Testing Security Headers
# Check headers with curl:
curl -I https://example.com
# Or check a specific header:
curl -sI https://example.com | grep -i "content-security-policy"
# Online tools:
# https://securityheaders.com — grades your headers
# https://observatory.mozilla.org — comprehensive security analysis
# https://csp-evaluator.withgoogle.com — CSP-specific evaluation
# Test CSP locally with report-only + logging:
# Set: Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report
# Add route to log violations:
app.post('/csp-report', express.json({ type: 'application/csp-report' }), (req, res) => {
console.log('CSP Violation:', JSON.stringify(req.body, null, 2))
res.status(204).send()
})
Security headers are cheap to add and meaningfully reduce your attack surface. The cost of implementing them — a few lines of configuration — is far less than dealing with a successful XSS attack that could have been stopped by a proper CSP.
→ Look up HTTP status codes and their meanings with the HTTP Status Codes reference.