Web Security Headers: A Complete Guide
Every security header explained with examples: CSP, HSTS, X-Frame-Options, Permissions-Policy, CORS headers, and how to deploy them safely without breaking your site.
What you'll learn
- ✓What each security header does and what attacks it prevents
- ✓How to configure CSP with nonces for inline scripts
- ✓How to enable HSTS without locking yourself out
- ✓How to set Permissions-Policy to restrict browser APIs
- ✓How to deploy headers incrementally with report-only mode
Prerequisites
- •Basic HTTP and web server concepts
- •Understanding of XSS and CSRF
Why Headers Matter
Security headers are HTTP response headers that tell the browser to restrict what a page can do. They are your second line of defense: even if an attacker injects code into your page, the browser refuses to execute it because the headers say no.
Without security headers, a browser trusts everything the page does. With them, you restrict scripts, frames, connections, and browser APIs to only what your application actually needs.
The Headers, One by One
Content-Security-Policy (CSP)
CSP tells the browser which sources of content are allowed. If a script tag loads from an unlisted origin, the browser blocks it.
Basic policy:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-abc123';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https://cdn.example.com;
font-src 'self' https://fonts.gstatic.com;
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
Key directives:
| Directive | Controls | Example |
|---|---|---|
default-src | Fallback for all resource types | 'self' |
script-src | JavaScript sources | 'self' 'nonce-...' |
style-src | CSS sources | 'self' 'unsafe-inline' |
img-src | Image sources | 'self' data: https://cdn.example.com |
connect-src | Fetch, XHR, WebSocket destinations | 'self' https://api.example.com |
frame-ancestors | Who can embed this page in an iframe | 'none' |
base-uri | Restricts <base> element | 'self' |
form-action | Where forms can submit to | 'self' |
Using nonces for inline scripts:
A nonce is a random value generated per request. The server puts it in the CSP header and in the script tag. The browser only runs inline scripts whose nonce matches.
// Server-side: generate nonce per request
import crypto from 'node:crypto';
const nonce = crypto.randomBytes(16).toString('base64');
// Set the CSP header
response.headers.set(
'Content-Security-Policy',
`script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'; default-src 'self'`,
);
<!-- In the HTML -->
<script nonce="generated-nonce-here">
console.log('This runs because the nonce matches');
</script>
<!-- This does NOT run because there is no matching nonce -->
<script>
console.log('Blocked by CSP');
</script>
Report-only mode lets you test a policy without blocking anything:
Content-Security-Policy-Report-Only:
default-src 'self';
report-uri /api/csp-report;
report-to csp-endpoint;
// Collect CSP violation reports
export const POST: APIRoute = async ({ request }) => {
const report = await request.json();
console.log('CSP Violation:', {
blockedUri: report['csp-report']?.['blocked-uri'],
violatedDirective: report['csp-report']?.['violated-directive'],
documentUri: report['csp-report']?.['document-uri'],
});
return new Response(null, { status: 204 });
};
Strict-Transport-Security (HSTS)
HSTS tells the browser to always use HTTPS for your domain. After the first visit, the browser will never make an HTTP request — it upgrades to HTTPS before the request leaves the machine.
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
max-age=31536000: Remember for 1 year (in seconds)includeSubDomains: Apply to all subdomainspreload: Submit to the HSTS preload list so browsers enforce HTTPS on the first visit
Deployment strategy:
# Start with a short max-age to test
Strict-Transport-Security: max-age=300
# After a week, increase
Strict-Transport-Security: max-age=86400
# After a month, go full
Strict-Transport-Security: max-age=31536000; includeSubDomains
# Finally, add preload
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Warning: once you enable HSTS with includeSubDomains, every subdomain must support HTTPS. If internal.example.com does not have a certificate, it becomes unreachable. Start without includeSubDomains and add it only when all subdomains are ready.
X-Content-Type-Options
Prevents MIME type sniffing. Without this, a browser might interpret a malicious HTML file served as text/plain as executable HTML.
X-Content-Type-Options: nosniff
Always set this. There is no reason not to.
X-Frame-Options
Controls whether your site can be loaded in an iframe. Prevents clickjacking attacks.
X-Frame-Options: DENY
Options:
DENY: Cannot be framed by anyoneSAMEORIGIN: Can only be framed by the same origin
Note: frame-ancestors in CSP is more flexible and supersedes X-Frame-Options, but set both for browser compatibility.
Referrer-Policy
Controls how much referrer information is sent when navigating away from your site.
Referrer-Policy: strict-origin-when-cross-origin
Options ranked from most to least restrictive:
no-referrer: Never send referrerstrict-origin-when-cross-origin: Full URL for same-origin, origin only for cross-origin over HTTPS, nothing for HTTPS-to-HTTPorigin-when-cross-origin: Full URL for same-origin, origin only for cross-originunsafe-url: Always send the full URL (do not use this)
Permissions-Policy
Restricts which browser APIs your site can use. Prevents embedded iframes from using your camera, microphone, etc.
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()
Common permissions:
Permissions-Policy:
camera=(),
microphone=(),
geolocation=(self),
payment=(self "https://pay.example.com"),
fullscreen=(self),
display-capture=(),
interest-cohort=()
(): Disabled entirely(self): Allowed only for this origin(self "https://example.com"): Allowed for this origin and the listed origin
Cross-Origin Headers
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Resource-Policy: same-origin
These three headers work together to enable SharedArrayBuffer and high-resolution timers (needed for WebAssembly threads) while preventing cross-origin attacks:
- COOP: Isolates your window from cross-origin popups
- COEP: Requires all cross-origin resources to explicitly opt in
- CORP: Declares whether a resource can be loaded cross-origin
Only enable these if you need SharedArrayBuffer. They break legitimate cross-origin resource loading.
Implementation: All Headers in One Place
Astro Middleware
// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
import crypto from 'node:crypto';
export const onRequest = defineMiddleware(async (context, next) => {
const nonce = crypto.randomBytes(16).toString('base64');
context.locals.nonce = nonce;
const response = await next();
// Security headers
response.headers.set(
'Content-Security-Policy',
[
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}'`,
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' data: https:`,
`font-src 'self'`,
`connect-src 'self'`,
`frame-ancestors 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
].join('; '),
);
response.headers.set(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains',
);
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
response.headers.set(
'Permissions-Policy',
'camera=(), microphone=(), geolocation=(), payment=()',
);
return response;
});
Nginx
# /etc/nginx/conf.d/security-headers.conf
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; frame-ancestors 'none'" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
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=()" always;
Vercel (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": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=()" },
{ "key": "Strict-Transport-Security", "value": "max-age=31536000; includeSubDomains" }
]
}
]
}
Netlify (_headers)
/*
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Strict-Transport-Security: max-age=31536000; includeSubDomains
Permissions-Policy: camera=(), microphone=(), geolocation=()
Testing Your Headers
SecurityHeaders.com
Visit securityheaders.com and enter your URL. It grades your headers from A+ to F.
Browser DevTools
Open Network tab, click on the document request, and check the Response Headers section.
curl
curl -I https://example.com | grep -i -E 'content-security|strict-transport|x-frame|x-content-type|referrer-policy|permissions-policy'
Deployment Checklist
- Start with
Content-Security-Policy-Report-Onlyto find what would break - Collect reports for a week, adjust the policy
- Switch to enforcing
Content-Security-Policy - Add
Strict-Transport-Securitywith a shortmax-agefirst - Gradually increase
max-ageover weeks - Add
includeSubDomainsonly when all subdomains support HTTPS - Set
X-Content-Type-Options,X-Frame-Options,Referrer-Policy, andPermissions-Policyimmediately (they rarely break anything)
Summary
Security headers are high-impact, low-effort defenses. CSP blocks injected scripts. HSTS forces HTTPS. X-Frame-Options prevents clickjacking. Permissions-Policy restricts browser API access. Deploy them incrementally using report-only mode, test with automated scanners, and treat them as non-negotiable infrastructure for any production web application.
Related articles
- Web Content Security Policy Explained
A practical tour of CSP: what each directive does, how nonces and hashes work, how to roll out a policy safely with report-only mode, and the mistakes that quietly weaken it.
- Web Web Authentication with OAuth 2.0 and OpenID Connect
Implement secure authentication using OAuth 2.0 and OIDC: authorization code flow with PKCE, ID tokens, scopes, token management, and common security pitfalls.
- Web Cookies vs localStorage vs sessionStorage
Compare the three main browser storage mechanisms by lifecycle, capacity, security, and use case so you pick the right tool for tokens, preferences, and state.
- Web CORS Explained In Depth
Understand Cross-Origin Resource Sharing: simple vs preflight requests, credentials, and the headers that decide whether your browser will let your fetch succeed.