Introduction
HTTP security headers are one of the most overlooked yet powerful defenses available to web applications. They require no code changes, no framework dependencies, and no runtime overhead. A single configuration in your web server or application framework can protect against cross-site scripting, clickjacking, MIME sniffing, protocol downgrade attacks, and more.
Despite their simplicity, many applications ship without proper security headers. According to security audits, the majority of websites are missing at least one critical security header, leaving them vulnerable to attacks that are trivially preventable. This guide covers every important security header, explains what each one protects against, shows how to configure them for popular frameworks, and provides a defense-in-depth strategy for production applications.
Security headers represent the lowest-effort, highest-impact security improvement you can make to a web application. Unlike input validation, authentication, or authorization, which require careful implementation and ongoing maintenance, security headers are a one-time configuration that provides persistent protection. They work at the browser level, intercepting potentially dangerous behavior before any application code runs. This makes them immune to application-level bugs and provides a safety net that catches vulnerabilities your code might miss.
This guide walks through each security header in detail, explains the attacks they prevent, provides configuration examples for popular frameworks and servers, and offers a practical implementation strategy that minimizes the risk of breaking existing functionality.
Why Security Headers Matter
Security headers work at the HTTP level, which means they are processed by the browser before any JavaScript runs. This makes them immune to client-side attacks and ensures protection even if your application code has vulnerabilities. They form a defense-in-depth layer that complements your application-level security.
Consider a scenario where an attacker finds a way to inject script tags into your application through a stored XSS vulnerability. Without Content Security Policy, the injected script executes with full access to cookies, session tokens, and the DOM. With a properly configured CSP, the injected script is blocked because it does not match the allowed script sources. The security header catches what the application code missed.
The same principle applies to other headers. X-Frame-Options prevents clickjacking even if your application does not implement frame-busting JavaScript. Strict-Transport-Security prevents protocol downgrade attacks even if your application does not check for HTTPS. Each header provides an independent layer of protection.
The Defense-in-Depth Model
Defense-in-depth is a security strategy that layers multiple independent protections so that if one layer fails, others remain. Security headers are a critical layer in this model because they operate at a different level than application code. While input validation and output encoding prevent XSS at the application level, CSP prevents XSS at the browser level. While HTTPS redirects prevent protocol downgrades at the application level, HSTS prevents them at the browser level.
This layered approach means that a vulnerability in one layer does not automatically compromise the entire system. An XSS vulnerability that bypasses input validation is still blocked by CSP. A misconfigured redirect that allows HTTP access is still blocked by HSTS. The more layers you have, the more resilient your application becomes.
Content-Security-Policy (CSP)
Content Security Policy is the most powerful and complex security header. It tells the browser which resources are allowed to load and from where. By restricting the sources of scripts, styles, images, fonts, and other resources, CSP prevents entire categories of attacks.
How CSP Prevents XSS
Cross-site scripting attacks work by injecting malicious script content into your application. The injected script runs in the context of your origin, giving it access to cookies, session tokens, and the ability to make requests to your API. CSP prevents this by specifying exactly which script sources are allowed.
When a browser encounters a script tag or receives a script response, it checks the CSP before executing it. If the script source is not listed in the CSP, the browser blocks execution and reports a violation. This means even if an attacker manages to inject script tags into your HTML, the browser will refuse to execute them.
CSP Directives Explained
The default-src directive sets the fallback policy for all resource types. If a specific directive is not set, the browser uses the default-src value. This makes it a good starting point for a restrictive policy.
The script-src directive controls which JavaScript sources are allowed. This is the most important directive for preventing XSS. It accepts source expressions like self for same-origin scripts, specific URLs for CDN-hosted scripts, nonce values for inline scripts that should be allowed, and hash values for specific inline script blocks.
The style-src directive controls which CSS sources are allowed. While CSS injection is less dangerous than JavaScript injection, it can still be used for data exfiltration and UI redressing attacks. The unsafe-inline keyword is commonly needed for styles because many frameworks inject inline styles dynamically.
The img-src directive controls which image sources are allowed. Images are generally lower risk, but restricting them can prevent data exfiltration through image URLs that encode stolen data in query parameters.
The connect-src directive controls which URLs can be loaded using script interfaces like fetch, XMLHttpRequest, and WebSocket. This is critical for preventing data exfiltration because it controls where your application can send data.
The font-src directive controls which font sources are allowed. Fonts are typically loaded from CDNs or same-origin servers, so this directive is straightforward to configure.
The frame-src and frame-ancestors directives control which pages can embed your content in frames and which pages your application can embed. frame-ancestors is the modern replacement for X-Frame-Options.
The object-src directive controls which plugins can be loaded. In modern web development, plugins are rarely needed, so this is typically set to none to block all plugin content.
The base-uri directive restricts the URLs that can be used in base elements. This prevents attackers from changing the base URL of your page, which could redirect all relative URLs to an attacker-controlled domain.
The form-action directive restricts the URLs that can be used as form action targets. This prevents attackers from hijacking form submissions to steal user input.
CSP with Nonces
Nonces are the recommended approach for allowing specific inline scripts while blocking all others. A nonce is a random value generated per request and included in both the CSP header and the script tag. The browser only executes scripts whose nonce matches the value in the CSP.
// Express.js with nonce generation
import crypto from 'crypto';
function generateNonce() {
return crypto.randomBytes(16).toString('base64');
}
app.use((req, res, next) => {
const nonce = generateNonce();
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: https:`,
`font-src 'self' https://fonts.gstatic.com`,
`connect-src 'self' https://api.example.com`,
`frame-ancestors 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
].join('; '));
next();
});The nonce must be unpredictable and unique per request. If an attacker can guess the nonce, they can include it in their injected scripts. Use a cryptographically secure random number generator to create nonces, and never reuse them across requests.
CSP with Hashes
Hashes provide an alternative to nonces for allowing specific inline scripts. Instead of including a nonce in the script tag, you calculate the hash of the script content and include it in the CSP. The browser calculates the hash of each inline script and compares it to the allowed hashes.
# Nginx configuration with script hashes
add_header Content-Security-Policy "
default-src 'self';
script-src 'self' 'sha256-abcdef1234567890...';
style-src 'self' 'unsafe-inline';
";Hashes are simpler than nonces because they do not require server-side generation. However, they are less flexible because any change to the script content requires updating the hash in the CSP. This makes them best suited for static inline scripts that do not change between requests.
CSP Reporting
CSP includes a reporting mechanism that sends violation reports to a specified URL. This is essential for monitoring and refining your CSP policy. When a script or resource is blocked by CSP, the browser sends a report containing the violated directive, the blocked URL, and the source file that triggered the violation.
# Enable CSP reporting
add_header Content-Security-Policy "
default-src 'self';
script-src 'self';
report-uri /csp-violations;
report-to csp-endpoint;
";Reporting should be enabled from the start, even in report-only mode. The reports help you understand which resources are being loaded and which would be blocked by a stricter policy. This information is invaluable for tuning your CSP without breaking functionality.
CSP in Report-Only Mode
Before enforcing CSP, deploy it in report-only mode using the Content-Security-Policy-Report-Only header. This header works exactly like Content-Security-Policy but does not block any resources. Instead, it only sends violation reports, allowing you to understand the impact of the policy without affecting users.
# Report-only mode (does not block anything)
add_header Content-Security-Policy-Report-Only "
default-src 'self';
script-src 'self';
report-uri /csp-violations;
";Run report-only mode for at least one to two weeks in production, monitoring the violation reports to identify legitimate resources that need to be added to the policy. Once the violation reports stabilize and you are confident the policy does not break functionality, switch to enforcement mode.
CSP in Next.js
Next.js provides built-in support for security headers through the headers configuration in next.config.js. The configuration allows you to specify headers for all routes or specific route patterns. For CSP, you need to construct the policy string carefully, ensuring all required sources are included.
// next.config.js
module.exports = {
async headers() {
return [
{
source: '/(.*)',
headers: [
{
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' https://fonts.gstatic.com",
"connect-src 'self' https://api.example.com",
"frame-ancestors 'none'",
].join('; '),
},
],
},
];
},
};In development mode, Next.js requires unsafe-eval and unsafe-inline for script-src because of its hot module replacement system. In production, these can be removed for a stricter policy. The style-src directive typically needs unsafe-inline because Next.js and many CSS-in-JS libraries inject inline styles.
Strict-Transport-Security (HSTS)
Strict-Transport-Security tells the browser to only access the site using HTTPS for a specified duration. Once the browser receives the HSTS header, it automatically converts all HTTP requests to HTTPS before sending them, preventing protocol downgrade attacks.
Why HSTS Is Necessary
Without HSTS, an attacker can intercept the initial HTTP request to your site before the redirect to HTTPS occurs. This is called a man-in-the-middle attack, and it allows the attacker to read and modify the traffic before the secure connection is established. HSTS prevents this by ensuring the browser never makes an HTTP request after seeing the header.
# Nginx HSTS configuration
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;The includeSubDomains directive extends HSTS protection to all subdomains. This is important because unprotected subdomains can be used as entry points for attacks on the main domain. However, be careful with includeSubDomains if you have subdomains that do not support HTTPS, as it will make them inaccessible.
The preload directive indicates that the site should be included in browser preload lists. Major browsers maintain lists of HSTS-enabled sites that are hardcoded into the browser itself. This provides protection even on the first visit, before the browser has received the HSTS header.
HSTS Preload Submission
Submitting your domain to the HSTS preload list requires meeting several criteria. Your site must serve a valid HTTPS certificate, include the HSTS header with a max-age of at least one year, include both includeSubDomains and preload directives, and redirect all HTTP traffic to HTTPS.
Once submitted and approved, your domain is added to the preload lists of Chrome, Firefox, Safari, Edge, and other browsers. This means the browser will never make an HTTP request to your domain, even on the first visit, providing the strongest possible protection against protocol downgrade attacks.
HSTS in Express
The helmet middleware for Express provides a simple way to configure HSTS. The hsts method accepts options for max-age, includeSubDomains, and preload. The middleware automatically sets the Strict-Transport-Security header on all responses.
import helmet from 'helmet';
app.use(helmet({
hsts: {
maxAge: 63072000, // 2 years in seconds
includeSubDomains: true,
preload: true,
},
}));X-Frame-Options
X-Frame-Options prevents your site from being embedded in frames, iframes, or objects on other sites. This protects against clickjacking attacks where an attacker overlays your site on top of a malicious page, tricking users into clicking on elements they did not intend to interact with.
# Nginx configuration
add_header X-Frame-Options "DENY" always;The DENY value completely prevents framing, which is the most secure option. The SAMEORIGIN value allows framing by pages on the same origin, which is useful if your application uses frames internally. The ALLOW-FROM value is deprecated and should not be used.
While CSP's frame-ancestors directive is the modern replacement for X-Frame-Options, it is still recommended to include X-Frame-Options for backward compatibility with older browsers that do not support CSP.
X-Content-Type-Options
X-Content-Type-Options with the nosniff value prevents the browser from guessing the MIME type of a response. Without this header, browsers may interpret files as a different MIME type than what the server specified, which can lead to security vulnerabilities.
# Nginx configuration
add_header X-Content-Type-Options "nosniff" always;For example, if an attacker uploads a file that contains HTML and JavaScript but is served with an image MIME type, the browser might interpret it as HTML and execute the JavaScript. With nosniff, the browser strictly follows the Content-Type header and refuses to interpret the file as anything other than what the server declared.
This header should always be set to nosniff. There are no legitimate use cases for MIME sniffing in modern web applications, and disabling it eliminates an entire class of vulnerabilities.
Permissions-Policy
Permissions-Policy controls which browser features and APIs can be used on your site. This includes access to hardware like cameras and microphones, device capabilities like geolocation and accelerometer, and payment APIs. By restricting access to these features, you reduce the attack surface and protect user privacy.
# Nginx Permissions-Policy configuration
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(self), payment=()" always;The policy uses a directive-based syntax similar to CSP. Each directive specifies which origins are allowed to use the feature. An empty value completely disables the feature, while a list of origins allows those specific origins to use it.
For most web applications, the default should be to disable all features that are not explicitly needed. If your application uses geolocation, enable it only for your origin. If your application uses the camera for video calls, enable it only for the relevant pages.
Permissions-Policy in Practice
A typical web application might only need a few browser features. Disabling all unnecessary features reduces the risk of abuse if your site is compromised. For example, if an attacker manages to inject script into your site, they cannot access the camera or microphone if Permissions-Policy has disabled those features.
The permissions policy should be configured at the application level and reviewed whenever new browser features are added. As browsers add new APIs, new permissions policy directives are introduced, and your policy should be updated to restrict them.
Referrer-Policy
Referrer-Policy controls how much referrer information is included when the browser makes requests. The referrer header tells the destination site where the user came from, which can leak sensitive information like URL paths, query parameters, and fragment identifiers.
# Nginx Referrer-Policy configuration
add_header Referrer-Policy "strict-origin-when-cross-origin" always;The strict-origin-when-cross-origin policy is the recommended default. It sends the full URL for same-origin requests, only the origin for cross-origin requests with the same protocol, and nothing when downgrading from HTTPS to HTTP. This provides a good balance between functionality and privacy.
For applications that handle sensitive data, consider using no-referrer to completely disable referrer information, or same-origin to only send referrers within your own site. The trade-off is that analytics and referral tracking will not work without referrer information.
Cross-Origin Headers
Cross-Origin-Embedder-Policy
Cross-Origin-Embedder-Policy with require-corp requires all cross-origin resources to explicitly opt in to being loaded. Resources must include a Cross-Origin-Resource-Policy header or be loaded with CORS. This prevents cross-origin resources from being loaded without their knowledge, which is a defense against Spectre-style attacks.
Cross-Origin-Opener-Policy
Cross-Origin-Opener-Policy with same-origin isolates your browsing context from cross-origin windows. This prevents cross-origin pages from holding a reference to your window object, which could be used to launch side-channel attacks.
Cross-Origin-Resource-Policy
Cross-Origin-Resource-Policy with same-origin prevents your resources from being loaded by cross-origin pages. This is a defense against attacks that exploit cross-origin resource loading to extract sensitive data.
Enabling SharedArrayBuffer
SharedArrayBuffer requires all three cross-origin headers to be set. This is because SharedArrayBuffer enables high-resolution timers that can be used for Spectre attacks. The cross-origin isolation headers ensure that only trusted code can access SharedArrayBuffer.
# Enable SharedArrayBuffer support
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;Framework-Specific Configuration
Express.js with Helmet
Helmet is the standard middleware for setting security headers in Express applications. It provides sensible defaults and allows customization of each header. The middleware sets headers on all responses automatically, so no per-route configuration is needed.
import helmet from 'helmet';
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"],
frameAncestors: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
},
},
hsts: {
maxAge: 63072000,
includeSubDomains: true,
preload: true,
},
referrerPolicy: {
policy: 'strict-origin-when-cross-origin',
},
}));The contentSecurityPolicy option accepts a directives object that maps CSP directives to arrays of allowed sources. The hsts option accepts max-age, includeSubDomains, and preload settings. The referrerPolicy option accepts a policy string.
For development environments, Helmet's defaults work well. For production, you should customize the CSP to be as restrictive as possible while still allowing your application to function.
Next.js
Next.js allows configuring security headers through the headers function in next.config.js. The function returns an array of header configurations, each specifying a source pattern and the headers to set. This allows different headers for different routes if needed.
For CSP in Next.js, you need to construct the policy string carefully. The development server requires unsafe-eval and unsafe-inline for script-src because of hot module replacement. In production, these can be removed. The style-src directive typically needs unsafe-inline because Next.js and CSS-in-JS libraries inject inline styles.
Nginx
Nginx uses the add_header directive to set security headers. Headers should be added in the server block to apply to all responses. For CSP, the header value can be constructed using nginx variables if dynamic values like nonces are needed.
# Complete Nginx security headers configuration
server {
listen 443 ssl http2;
server_name example.com;
# HSTS
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# CSP
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://api.example.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
# X-Frame-Options
add_header X-Frame-Options "DENY" always;
# X-Content-Type-Options
add_header X-Content-Type-Options "nosniff" always;
# Referrer-Policy
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Permissions-Policy
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(self), payment=()" always;
# Cross-Origin headers
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
}The always parameter ensures the header is set even for error responses, which is important because error pages can still be targeted by attacks.
Testing and Monitoring
Online Testing Tools
SecurityHeaders.com scans your site and grades your security headers from A to F. It provides detailed information about each header, including what it protects against and how to configure it. Mozilla Observatory provides a more comprehensive security assessment that includes header analysis along with other security checks.
CLI Testing with curl
# Check all security headers
curl -I https://example.com
# Expected output includes:
# strict-transport-security: max-age=63072000; includeSubDomains; preload
# content-security-policy: default-src 'self'; ...
# x-frame-options: DENY
# x-content-type-options: nosniff
# referrer-policy: strict-origin-when-cross-origin
# permissions-policy: camera=(), microphone=(), ...Using curl to check headers is a quick way to verify configuration. The curl command with the dash capital I flag shows response headers without the response body. This is useful for verifying that headers are set correctly after configuration changes.
CSP Evaluator
Google's CSP Evaluator analyzes your Content Security Policy and identifies weaknesses. It checks for common misconfigurations like allowing unsafe-inline in script-src, using data URLs in script-src, and other patterns that reduce CSP's effectiveness.
Continuous Monitoring
Security headers should be monitored continuously, not just during initial setup. CSP reports should be collected and analyzed to identify new violations that might indicate attacks or misconfigurations. Automated tests should verify that headers are present and correctly configured after every deployment.
Security Headers Checklist
| Header | Value | Purpose |
|---|---|---|
| Content-Security-Policy | Restrictive policy with nonces | Prevent XSS and resource injection |
| Strict-Transport-Security | max-age=63072000; includeSubDomains; preload | Enforce HTTPS |
| X-Frame-Options | DENY | Prevent clickjacking |
| X-Content-Type-Options | nosniff | Prevent MIME sniffing |
| Referrer-Policy | strict-origin-when-cross-origin | Control referrer leakage |
| Permissions-Policy | Restrict unnecessary features | Limit browser API access |
| Cross-Origin-Embedder-Policy | require-corp | Spectre defense |
| Cross-Origin-Opener-Policy | same-origin | Window reference isolation |
| Cross-Origin-Resource-Policy | same-origin | Resource loading control |
Best Practices
- Start with report-only mode — Deploy CSP in report-only mode before enforcing to avoid breaking functionality
- Use nonces over unsafe-inline — Nonces provide better security than allowing all inline scripts
- Enable HSTS preload — Submit to the preload list for maximum HTTPS protection
- Test thoroughly — CSP can break functionality if misconfigured, so test all application features
- Monitor CSP reports — Collect and analyze violation reports to refine your policy
- Use framework middleware — Helmet for Express, built-in headers configuration for Next.js
- Keep headers updated — Review and update headers as your application evolves and new security features are introduced
- Automate testing — Include security header checks in your CI/CD pipeline to catch regressions
Common Pitfalls
| Pitfall | Impact | Solution |
|---|---|---|
| Overly permissive CSP | No XSS protection | Start restrictive, relax as needed based on reports |
| Not testing CSP in report-only | Broken functionality in production | Always test in report-only first |
| Missing HSTS preload | Incomplete HTTPS protection | Submit to preload list after meeting requirements |
| Ignoring CSP reports | Missed violations and attacks | Monitor reports continuously |
| Hardcoded nonces | Security vulnerability | Generate unique nonces per request |
| Not setting nosniff | MIME sniffing vulnerabilities | Always include X-Content-Type-Options: nosniff |
| Missing Permissions-Policy | Unnecessary API access | Disable all unused browser features |
| No cross-origin headers | Spectre vulnerability | Set all three cross-origin headers |
Conclusion
Security headers provide a powerful, zero-overhead layer of defense for web applications. CSP prevents XSS by controlling which resources can load. HSTS enforces HTTPS and prevents protocol downgrade attacks. X-Frame-Options stops clickjacking. Permissions-Policy restricts browser feature access. Together, these headers form a comprehensive defense-in-depth strategy.
The key to successful implementation is a methodical approach. Start with report-only mode for CSP, monitor violation reports, gradually tighten the policy, and eventually switch to enforcement. For other headers, the configuration is straightforward and can be done immediately.
Security headers are free, require no code changes, and provide significant protection. There is no reason not to implement them. If your application does not have proper security headers configured, that should be the first security improvement you make.
Key takeaways:
- CSP is the most powerful header — Controls which resources can load and prevents XSS
- Use nonces for inline scripts — More secure than unsafe-inline
- HSTS enforces HTTPS — Submit to the preload list for maximum protection
- Start with report-only mode — Test CSP before enforcing to avoid breaking functionality
- Monitor CSP reports — Track violations to identify attacks and refine your policy
- Use framework middleware — Helmet for Express, built-in configuration for Next.js
- Start restrictive — It is easier to relax a policy than to tighten one that is too permissive
- Automate testing — Include security header verification in your CI/CD pipeline