MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Web Security Headers: A Complete Guide

Comprehensive guide to web security headers: CSP, HSTS, X-Frame-Options, Permissions-Policy, and defense-in-depth strategies.

SecurityHTTP HeadersWebOWASP

By MinhVo

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.

Web security

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.

CSP configuration

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.

Security headers configuration

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

HeaderValuePurpose
Content-Security-PolicyRestrictive policy with noncesPrevent XSS and resource injection
Strict-Transport-Securitymax-age=63072000; includeSubDomains; preloadEnforce HTTPS
X-Frame-OptionsDENYPrevent clickjacking
X-Content-Type-OptionsnosniffPrevent MIME sniffing
Referrer-Policystrict-origin-when-cross-originControl referrer leakage
Permissions-PolicyRestrict unnecessary featuresLimit browser API access
Cross-Origin-Embedder-Policyrequire-corpSpectre defense
Cross-Origin-Opener-Policysame-originWindow reference isolation
Cross-Origin-Resource-Policysame-originResource loading control

Best Practices

  1. Start with report-only mode — Deploy CSP in report-only mode before enforcing to avoid breaking functionality
  2. Use nonces over unsafe-inline — Nonces provide better security than allowing all inline scripts
  3. Enable HSTS preload — Submit to the preload list for maximum HTTPS protection
  4. Test thoroughly — CSP can break functionality if misconfigured, so test all application features
  5. Monitor CSP reports — Collect and analyze violation reports to refine your policy
  6. Use framework middleware — Helmet for Express, built-in headers configuration for Next.js
  7. Keep headers updated — Review and update headers as your application evolves and new security features are introduced
  8. Automate testing — Include security header checks in your CI/CD pipeline to catch regressions

Common Pitfalls

PitfallImpactSolution
Overly permissive CSPNo XSS protectionStart restrictive, relax as needed based on reports
Not testing CSP in report-onlyBroken functionality in productionAlways test in report-only first
Missing HSTS preloadIncomplete HTTPS protectionSubmit to preload list after meeting requirements
Ignoring CSP reportsMissed violations and attacksMonitor reports continuously
Hardcoded noncesSecurity vulnerabilityGenerate unique nonces per request
Not setting nosniffMIME sniffing vulnerabilitiesAlways include X-Content-Type-Options: nosniff
Missing Permissions-PolicyUnnecessary API accessDisable all unused browser features
No cross-origin headersSpectre vulnerabilitySet 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:

  1. CSP is the most powerful header — Controls which resources can load and prevents XSS
  2. Use nonces for inline scripts — More secure than unsafe-inline
  3. HSTS enforces HTTPS — Submit to the preload list for maximum protection
  4. Start with report-only mode — Test CSP before enforcing to avoid breaking functionality
  5. Monitor CSP reports — Track violations to identify attacks and refine your policy
  6. Use framework middleware — Helmet for Express, built-in configuration for Next.js
  7. Start restrictive — It is easier to relax a policy than to tighten one that is too permissive
  8. Automate testing — Include security header verification in your CI/CD pipeline