HTTP Security Headers
HTTP security headers are your application's first line of defense, instructing browsers how to handle your content securely. Properly configured headers can prevent XSS, clickjacking, and man-in-the-middle attacks—even when application code has vulnerabilities. This article covers CORS, CSP, HSTS, and the essential minor headers every production site should set.
CORS (Cross-Origin Resource Sharing)
CORS is one of the most misunderstood web security mechanisms. Here's the key insight: CORS protects users, not servers. Your server can still receive and process any request. CORS tells browsers whether to expose the response to JavaScript running on other origins.
Understanding Same-Origin Policy
Browsers enforce the same-origin policy: JavaScript on https://app.example.com cannot read responses from https://api.other.com. This prevents malicious sites from stealing your data using your authenticated sessions.
An origin consists of scheme + host + port:
https://example.comandhttp://example.com→ different origins (scheme)https://example.comandhttps://api.example.com→ different origins (host)https://example.comandhttps://example.com:8080→ different origins (port)
Simple vs. Preflight Requests
Simple requests (roughly matching what HTML forms can do) go directly to the server:
- Methods:
GET,HEAD,POST - Headers: Only
Accept,Accept-Language,Content-Language,Content-Type(with valueapplication/x-www-form-urlencoded,multipart/form-data, ortext/plain)
All other requests trigger a preflight:
- Browser sends an
OPTIONSrequest asking "is this allowed?" - Server responds with CORS headers
- If allowed, browser sends the actual request
- If denied, browser blocks and throws an error
# Preflight request
OPTIONS /api/data HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
# Preflight response
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
CORS Headers Explained
# Which origin(s) can access the response
Access-Control-Allow-Origin: https://app.example.com
# Or: Access-Control-Allow-Origin: * (any origin, but see warnings below)
# Which methods are allowed
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
# Which custom headers are allowed in the request
Access-Control-Allow-Headers: Content-Type, Authorization, X-Request-ID
# Whether cookies/auth can be included (cannot use * with this)
Access-Control-Allow-Credentials: true
# How long to cache the preflight response (seconds)
Access-Control-Max-Age: 86400
# Which headers the client JavaScript can read from the response
Access-Control-Expose-Headers: X-Total-Count, X-Page-Count
Common CORS Configurations
Express.js
import cors from 'cors';
import express from 'express';
const app = express();
// Development: Allow all origins (NOT for production!)
app.use(cors());
// Production: Specific origin(s)
app.use(cors({
origin: 'https://app.example.com',
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 86400,
}));
// Multiple origins with validation
app.use(cors({
origin: (origin, callback) => {
const allowedOrigins = [
'https://app.example.com',
'https://admin.example.com',
];
// Allow requests with no origin (server-to-server, curl, etc.)
if (!origin) {
return callback(null, true);
}
if (allowedOrigins.includes(origin)) {
callback(null, origin); // Return the specific origin, not *
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true,
}));
Astro API Routes
// src/pages/api/data.json.ts
export const GET: APIRoute = async ({ request }) => {
const allowedOrigins = ['https://app.example.com'];
const origin = request.headers.get('Origin') || '';
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (allowedOrigins.includes(origin)) {
headers['Access-Control-Allow-Origin'] = origin;
headers['Access-Control-Allow-Credentials'] = 'true';
}
return new Response(JSON.stringify({ data: 'value' }), { headers });
};
// Handle preflight
export const OPTIONS: APIRoute = async ({ request }) => {
const origin = request.headers.get('Origin') || '';
const allowedOrigins = ['https://app.example.com'];
const headers: Record<string, string> = {
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
};
if (allowedOrigins.includes(origin)) {
headers['Access-Control-Allow-Origin'] = origin;
headers['Access-Control-Allow-Credentials'] = 'true';
}
return new Response(null, { status: 204, headers });
};
Nginx
location /api/ {
# Handle preflight
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' 'https://app.example.com';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization';
add_header 'Access-Control-Max-Age' '86400';
add_header 'Content-Length' '0';
return 204;
}
# Regular requests
add_header 'Access-Control-Allow-Origin' 'https://app.example.com';
add_header 'Access-Control-Allow-Credentials' 'true';
proxy_pass http://backend;
}
CORS Gotchas
1. Wildcard with credentials is forbidden:
# This is INVALID and browsers will reject it
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
If you need credentials, you must return the specific requesting origin.
2. Access-Control-Allow-Origin: * means public API:
Only use wildcard for truly public APIs. Even then, consider if you want the public internet calling your endpoints.
3. CORS doesn't protect your server: An attacker can still call your API from curl, Postman, or their own server. CORS only controls browser behavior. You still need authentication and authorization.
4. Don't reflect arbitrary origins:
// DANGEROUS: Reflects any origin back
const origin = req.headers.origin;
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
// This is effectively the same as * + credentials = security bypass
5. Caching preflight appropriately:
Short Access-Control-Max-Age means more OPTIONS requests. Too long means configuration changes take time to propagate. 86400 (24 hours) is common.
CSP (Content Security Policy)
Content Security Policy is the ultimate XSS mitigation. Even if an attacker injects a script tag, CSP can prevent it from executing. CSP tells the browser which sources of content are legitimate.
Basic CSP
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline'
This policy:
- Only loads resources from the same origin by default
- Allows scripts from same origin and
https://cdn.example.com - Allows styles from same origin plus inline styles
Key Directives
Content-Security-Policy:
# Fallback for other directives
default-src 'self';
# JavaScript sources
script-src 'self' https://cdn.example.com;
# CSS sources
style-src 'self' https://fonts.googleapis.com;
# Images
img-src 'self' data: https:;
# Fonts
font-src 'self' https://fonts.gstatic.com;
# AJAX, WebSocket, EventSource
connect-src 'self' https://api.example.com wss://ws.example.com;
# <object>, <embed>, <applet>
object-src 'none';
# <frame>, <iframe>
frame-src 'self' https://www.youtube.com;
# Form action destinations
form-action 'self';
# Allowed parents for embedding your page
frame-ancestors 'none';
# Base URL for relative URLs
base-uri 'self';
# Where to send CSP violation reports
report-uri /csp-report;
Source Values
| Value | Meaning |
|---|---|
'self' |
Same origin as the document |
'none' |
Blocks all sources for this directive |
'unsafe-inline' |
Allows inline scripts/styles (avoid if possible) |
'unsafe-eval' |
Allows eval() and similar (avoid) |
https: |
Any HTTPS URL |
data: |
data: URIs (for inline images) |
https://example.com |
Specific origin |
https://*.example.com |
Wildcard subdomain |
'nonce-abc123' |
Specific inline script/style with matching nonce |
'sha256-...' |
Inline script/style with matching hash |
Using Nonces (Recommended for Inline Scripts)
Nonces allow specific inline scripts while blocking injected ones:
// Server: Generate a random nonce per request
import crypto from 'crypto';
function generateNonce(): string {
return crypto.randomBytes(16).toString('base64');
}
// Express middleware
app.use((req, res, next) => {
res.locals.nonce = generateNonce();
res.setHeader(
'Content-Security-Policy',
`default-src 'self'; script-src 'self' 'nonce-${res.locals.nonce}'`
);
next();
});
<!-- HTML with nonce -->
<!DOCTYPE html>
<html>
<head>
<!-- This script runs because nonce matches -->
<script nonce="abc123">
console.log('Legitimate inline script');
</script>
<!-- This BLOCKED - no nonce or wrong nonce -->
<script>
console.log('Injected by attacker');
</script>
</head>
</html>
For Astro, set nonces via middleware:
// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
import crypto from 'crypto';
export const onRequest = defineMiddleware(async (context, next) => {
const nonce = crypto.randomBytes(16).toString('base64');
context.locals.nonce = nonce;
const response = await next();
// Clone response to modify headers
const newResponse = new Response(response.body, response);
newResponse.headers.set(
'Content-Security-Policy',
`default-src 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'`
);
return newResponse;
});
CSP Report-Only Mode
Test your CSP before enforcing it:
# Reports violations but doesn't block
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report
# Collect reports
POST /csp-report HTTP/1.1
Content-Type: application/csp-report
{
"csp-report": {
"document-uri": "https://example.com/page",
"violated-directive": "script-src 'self'",
"blocked-uri": "https://evil.com/script.js",
"original-policy": "default-src 'self'"
}
}
// Express endpoint to collect reports
app.post('/csp-report', express.json({ type: 'application/csp-report' }), (req, res) => {
console.log('CSP Violation:', req.body['csp-report']);
// Send to logging service
res.status(204).end();
});
Strict CSP (Google's Recommended Approach)
Google's strict CSP uses nonces and blocks most XSS:
Content-Security-Policy:
object-src 'none';
script-src 'nonce-{random}' 'strict-dynamic';
base-uri 'none';
report-uri /csp-report
'strict-dynamic': Scripts loaded by trusted scripts are also trusted- No need to whitelist CDN origins—the nonce propagates trust
- Easier to maintain than long origin whitelists
Common CSP Gotchas
1. 'unsafe-inline' defeats the purpose for scripts:
If you allow 'unsafe-inline' for scripts, attackers can inject inline scripts. Use nonces instead.
2. 'unsafe-eval' is dangerous:
Some libraries require eval(). Avoid them if possible, or scope the CSP:
# Only allow eval in specific context
Content-Security-Policy: script-src 'self' 'unsafe-eval' https://legacy-lib.example.com
3. Browser extensions break your CSP reports:
You'll get reports for Chrome extensions, ad blockers, etc. Filter by blocked-uri schemes:
// Ignore extension-related violations
if (report.blockedUri.startsWith('chrome-extension://')) return;
if (report.blockedUri.startsWith('moz-extension://')) return;
4. Inline event handlers don't work with strict CSP:
<!-- This is blocked by strict CSP -->
<button onclick="doSomething()">Click</button>
<!-- Do this instead -->
<button id="myButton">Click</button>
<script nonce="abc123">
document.getElementById('myButton').addEventListener('click', doSomething);
</script>
HSTS (Strict-Transport-Security)
HSTS forces browsers to use HTTPS for all future requests to your domain. This prevents:
- Protocol downgrade attacks
- SSL stripping (intercepting HTTP before redirect to HTTPS)
- Cookie theft over HTTP
Basic HSTS
Strict-Transport-Security: max-age=31536000
This tells browsers: "For the next year, always use HTTPS for this domain, even if the user types http://."
Full HSTS Configuration
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
| Directive | Meaning |
|---|---|
max-age=31536000 |
Remember this for 1 year (in seconds) |
includeSubDomains |
Apply to all subdomains too |
preload |
Consent to be included in browser preload lists |
HSTS Preloading
The HSTS preload list is built into browsers. Sites on the list are HTTPS-only from the first visit, even before seeing the header.
Requirements for preloading:
- Serve a valid HTTPS certificate
- Redirect HTTP to HTTPS on the same host
- Serve HSTS header with
max-ageof at least 1 year - Include
includeSubDomains - Include
preload - All subdomains must support HTTPS
Submit at hstspreload.org.
Warning: Preloading is hard to undo. If you later need HTTP, you'll have to wait for browser updates and users to get them.
HSTS Implementation
// Express
app.use((req, res, next) => {
// Only set HSTS on HTTPS connections
if (req.secure || req.headers['x-forwarded-proto'] === 'https') {
res.setHeader(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains; preload'
);
}
next();
});
# Nginx
server {
listen 443 ssl;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
}
HSTS Gotchas
1. Don't set HSTS over HTTP: HSTS over HTTP is ignored (an attacker could inject it). Only send over HTTPS.
2. Start with a short max-age:
Test with max-age=300 (5 minutes) before committing to a year.
3. All subdomains must support HTTPS:
If you use includeSubDomains and old-internal.example.com doesn't have HTTPS, it becomes inaccessible.
Essential Minor Headers
These headers are quick wins—easy to add and provide meaningful protection.
X-Content-Type-Options
Prevents browsers from MIME-sniffing a response away from the declared content type:
X-Content-Type-Options: nosniff
Without this, a browser might interpret a file as JavaScript or HTML even if you serve it as plain text, enabling XSS through uploaded files.
X-Frame-Options
Prevents your site from being embedded in iframes (clickjacking protection):
X-Frame-Options: DENY
| Value | Meaning |
|---|---|
DENY |
Never allow framing |
SAMEORIGIN |
Only allow framing by same origin |
ALLOW-FROM https://example.com |
Only allow specific origin (deprecated, use CSP instead) |
Note: frame-ancestors in CSP is more flexible and modern:
Content-Security-Policy: frame-ancestors 'self' https://trusted-partner.com
Referrer-Policy
Controls how much URL information is sent with requests:
Referrer-Policy: strict-origin-when-cross-origin
| Value | Behavior |
|---|---|
no-referrer |
Never send Referer header |
origin |
Send only the origin (no path) |
same-origin |
Full URL for same-origin, nothing cross-origin |
strict-origin |
Origin for HTTPS→HTTPS, nothing for HTTPS→HTTP |
strict-origin-when-cross-origin |
Full URL same-origin, origin cross-origin (good default) |
This prevents leaking sensitive URL paths (like password reset tokens) to third-party sites.
Permissions-Policy (formerly Feature-Policy)
Controls which browser features your site can use:
Permissions-Policy: camera=(), microphone=(), geolocation=(self), payment=(self "https://payments.example.com")
This says: no camera, no microphone, geolocation only for self, payment only for self and payment provider.
Useful for:
- Preventing third-party scripts from accessing sensitive APIs
- Defense in depth if third-party code is compromised
Cache-Control for Sensitive Data
Don't let browsers or proxies cache sensitive responses:
Cache-Control: no-store, max-age=0
For authenticated content:
Cache-Control: private, no-cache, no-store, must-revalidate
Pragma: no-cache # For HTTP/1.0 compatibility
Complete Header Configuration
Express.js with Helmet
import express from 'express';
import helmet from 'helmet';
const app = express();
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "https://cdn.example.com"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://api.example.com"],
fontSrc: ["'self'", "https://fonts.gstatic.com"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
},
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
},
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));
Nginx
# Add to server or location block
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(self)" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; object-src 'none'; frame-ancestors 'none'" always;
Cloudflare / Vercel / Edge Configuration
// vercel.json
{
"headers": [
{
"source": "/(.*)",
"headers": [
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "key": "X-Frame-Options", "value": "DENY" },
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
{ "key": "Strict-Transport-Security", "value": "max-age=31536000; includeSubDomains; preload" },
{ "key": "Content-Security-Policy", "value": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'" }
]
}
]
}
Testing Your Headers
Online Tools
- Security Headers - Grades your headers A-F
- CSP Evaluator - Analyzes CSP for weaknesses
- Mozilla Observatory - Comprehensive security scan
Command Line
# Check headers
curl -I https://example.com
# Check for specific headers
curl -s -D - https://example.com -o /dev/null | grep -i "content-security-policy\|strict-transport\|x-frame"
Browser DevTools
Open DevTools → Network tab → Click a request → Headers tab. Look for Response Headers section.
See Also
- Common Vulnerabilities (OWASP Top 10) - XSS, CSRF, and injection attacks
- Web Security Overview - Defense in depth principles
- MDN: HTTP Headers - Complete header reference
- Content Security Policy Reference - Interactive CSP builder
- HSTS Preload List - Submit your site for HSTS preloading