A

HTTP Security Headers

securitycorscsphstshttp-headersweb-securityxss-prevention

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.com and http://example.com → different origins (scheme)
  • https://example.com and https://api.example.com → different origins (host)
  • https://example.com and https://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 value application/x-www-form-urlencoded, multipart/form-data, or text/plain)

All other requests trigger a preflight:

  1. Browser sends an OPTIONS request asking "is this allowed?"
  2. Server responds with CORS headers
  3. If allowed, browser sends the actual request
  4. 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

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();
});

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:

  1. Serve a valid HTTPS certificate
  2. Redirect HTTP to HTTPS on the same host
  3. Serve HSTS header with max-age of at least 1 year
  4. Include includeSubDomains
  5. Include preload
  6. 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

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

Last updated: March 23, 2026