StackPractices
beginner By Mathias Paulenko

Security Headers

Harden web applications with HTTP security headers: CSP, HSTS, X-Frame-Options, and a thorough security header checklist.

Topics: security

Overview

HTTP security headers are the first line of defense for web applications. They instruct browsers on how to behave when rendering your site — blocking XSS, preventing clickjacking, enforcing HTTPS, and controlling what external resources can load. Properly configured, they can stop entire classes of attacks without changing a line of application code.

When to Use

Use this resource when:

  • Hardening production web applications against common browser-based attacks
  • Preparing for security audits that check OWASP secure headers
  • Serving user-generated content that could contain malicious scripts
  • Embedding your application in third-party sites via iframes

Solution

Express.js Security Headers Middleware

const helmet = require('helmet');

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "'unsafe-inline'", "https://analytics.example.com"],
      styleSrc: ["'self'", "https://fonts.googleapis.com"],
      imgSrc: ["'self'", "data:", "https://cdn.example.com"],
      connectSrc: ["'self'", "https://api.example.com"],
      fontSrc: ["'self'", "https://fonts.gstatic.com"],
      frameAncestors: ["'none'"]
    }
  },
  hsts: {
    maxAge: 31536000,
    includeSubDomains: true,
    preload: true
  },
  referrerPolicy: { policy: 'strict-origin-when-cross-origin' }
}));

Nginx Security Headers

server {
    add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline';" always;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;

    location / {
        proxy_pass http://backend;
    }
}

Security Header Audit Script (Bash)

#!/bin/bash
URL="https://example.com"

echo "Checking security headers for $URL..."
curl -sI "$URL" | grep -iE \
  "(content-security-policy|strict-transport-security|x-content-type-options|x-frame-options|referrer-policy|permissions-policy)"

Explanation

Essential headers:

HeaderPurposeRisk if Missing
Content-Security-Policy (CSP)Controls loaded scripts/stylesXSS via injected <script> tags
Strict-Transport-Security (HSTS)Forces HTTPSSSL stripping attacks
X-Content-Type-OptionsPrevents MIME sniffingDrive-by downloads via fake content types
X-Frame-OptionsBlocks clickjackingUI redressing attacks
Referrer-PolicyControls referrer leakageSensitive URLs exposed to third parties
Permissions-PolicyRestricts browser APIsUnauthorized camera, microphone, geolocation access

Variants

FrameworkLibraryEase
ExpressHelmetOne line
Fastify@fastify/helmetPlugin
Djangodjango-cspMiddleware
SpringSpring SecurityConfiguration
Railssecure_headersGem
NginxNativeManual

What Works

  • Start with report-only CSP: Content-Security-Policy-Report-Only logs violations without blocking
  • Use nonce for inline scripts: <script nonce="random-value"> instead of 'unsafe-inline'
  • Test before enforcing: CSP can break third-party widgets, payment forms, and analytics
  • Submit to HSTS preload list: Include your domain in browser preload lists for automatic HTTPS
  • Set appropriate frame ancestors: Use CSP frame-ancestors instead of legacy X-Frame-Options

Common Mistakes

  1. Overly permissive CSP: default-src * allows any domain to execute scripts
  2. Missing on API responses: Browsers ignore headers on 404/500 pages, but attackers target them
  3. Forgetting API endpoints: JSON APIs should still send CORS and CSP headers
  4. Unreported violations: Without report-uri, you won’t know when legitimate content is blocked
  5. Inconsistent headers: Nginx proxy overriding application headers creates gaps

Advanced Solutions

CSP report-only with violation reporting

Deploy CSP in report-only mode first to catch violations without breaking anything:

const express = require('express');
const helmet = require('helmet');

const app = express();

// Report-only CSP: logs violations but does not block
app.use(helmet.contentSecurityPolicy({
  useDefaults: false,
  directives: {
    'default-src': ["'self'"],
    'script-src': ["'self'", 'https://cdn.example.com'],
    'style-src': ["'self'", 'https://fonts.googleapis.com'],
    'img-src': ["'self'", 'data:', 'https:'],
    'connect-src': ["'self'", 'https://api.example.com'],
    'report-uri': ['/api/csp-report'],
  },
  reportOnly: true,
}));

// Endpoint to receive CSP violation reports
app.post('/api/csp-report', express.json({ type: 'application/csp-report' }), (req, res) => {
  const report = req.body['csp-report'];
  console.warn('CSP Violation:', {
    'document-uri': report['document-uri'],
    'violated-directive': report['violated-directive'],
    'blocked-uri': report['blocked-uri'],
    'line-number': report['line-number'],
    'source-file': report['source-file'],
  });
  res.status(204).end();
});

// After collecting reports for 1-2 weeks, switch to enforcing CSP
// by removing reportOnly: true and keeping the report-uri directive

Django security headers middleware

# settings.py
MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
    # ... other middleware
]

SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
SECURE_SSL_REDIRECT = True
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
X_FRAME_OPTIONS = 'DENY'

# CSP via django-csp
CSP_DEFAULT_SRC = ("'self'",)
CSP_SCRIPT_SRC = ("'self'", 'https://cdn.example.com')
CSP_STYLE_SRC = ("'self'", 'https://fonts.googleapis.com')
CSP_IMG_SRC = ("'self'", 'data:', 'https:')
CSP_CONNECT_SRC = ("'self'", 'https://api.example.com')
CSP_FONT_SRC = ("'self'", 'https://fonts.gstatic.com')
CSP_FRAME_ANCESTORS = ("'none'",)
CSP_REPORT_URI = '/csp-report/'

Spring Boot security headers

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.header.writers.XXssProtectionHeaderWriter;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .headers(headers -> headers
                .contentSecurityPolicy(csp -> csp.policyDirectives(
                    "default-src 'self'; " +
                    "script-src 'self' https://cdn.example.com; " +
                    "style-src 'self' https://fonts.googleapis.com; " +
                    "img-src 'self' data: https:; " +
                    "connect-src 'self' https://api.example.com; " +
                    "frame-ancestors 'none'; " +
                    "base-uri 'self'; " +
                    "object-src 'none'"
                ))
                .httpStrictTransportSecurity(hsts -> hsts
                    .maxAgeInSeconds(31536000)
                    .includeSubDomains(true)
                    .preload(true)
                )
                .contentTypeOptions(opts -> {}) // X-Content-Type-Options: nosniff
                .frameOptions(frame -> frame.deny())
                .xssProtection(xss -> xss.headerValue(
                    XXssProtectionHeaderWriter.HeaderValue.ENABLED_MODE_BLOCK
                ))
                .referrerPolicy(referrer -> referrer.policy(
                    org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN
                ))
            )
            .csrf(csrf -> csrf.disable()); // Disable CSRF for API-only apps

        return http.build();
    }
}

Cross-Origin isolation headers (COEP, COOP, CORP)

For applications that need SharedArrayBuffer or other advanced APIs, set cross-origin isolation headers:

# Nginx: enable cross-origin isolation
server {
    # Cross-Origin Embedder Policy: require CORS for cross-origin resources
    add_header Cross-Origin-Embedder-Policy "require-corp" always;

    # Cross-Origin Opener Policy: isolate browsing context group
    add_header Cross-Origin-Opener-Policy "same-origin" always;

    # Cross-Origin Resource Policy: restrict who can embed this resource
    add_header Cross-Origin-Resource-Policy "same-origin" always;

    # Standard security headers
    add_header Content-Security-Policy "default-src 'self'" always;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" 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 "geolocation=(), microphone=(), camera=(), payment=(), usb=()" always;
}

Automated security header audit (Python)

import requests
from typing import dict

REQUIRED_HEADERS = {
    'content-security-policy': 'Blocks XSS and resource injection',
    'strict-transport-security': 'Forces HTTPS',
    'x-content-type-options': 'Prevents MIME sniffing',
    'x-frame-options': 'Prevents clickjacking',
    'referrer-policy': 'Controls referrer leakage',
    'permissions-policy': 'Restricts browser APIs',
}

def audit_security_headers(url: str) -> dict:
    """Audit a URL for missing security headers."""
    try:
        response = requests.head(url, allow_redirects=True, timeout=10)
    except requests.RequestException as e:
        return {'error': str(e)}

    results = {
        'url': url,
        'status': response.status_code,
        'present': {},
        'missing': {},
        'warnings': [],
    }

    for header, purpose in REQUIRED_HEADERS.items():
        value = response.headers.get(header)
        if value:
            results['present'][header] = value
            # Check for weak configurations
            if header == 'content-security-policy' and "'unsafe-inline'" in value:
                results['warnings'].append(
                    f"CSP contains 'unsafe-inline' — consider using nonces"
                )
            if header == 'strict-transport-security' and 'max-age=0' in value:
                results['warnings'].append(
                    "HSTS max-age is 0 — HSTS is effectively disabled"
                )
        else:
            results['missing'][header] = purpose

    return results

# Usage
audit = audit_security_headers('https://example.com')
print(f"Present: {len(audit['present'])}/{len(REQUIRED_HEADERS)}")
if audit['missing']:
    print("Missing headers:")
    for h, p in audit['missing'].items():
        print(f"  - {h}: {p}")
if audit['warnings']:
    print("Warnings:")
    for w in audit['warnings']:
        print(f"  - {w}")

Frequently Asked Questions

Do security headers protect APIs?

Partially. CORS, HSTS, and CSP matter for browser clients. For machine-to-machine APIs, focus on authentication and TLS.

Will CSP break my analytics?

Only if you forget to whitelist your analytics domain. Add it to script-src and connect-src directives.

What's the difference between X-Frame-Options and CSP frame-ancestors?

CSP frame-ancestors is the modern standard. X-Frame-Options is deprecated but still useful for older browsers.