正在加载,请稍候…

HTTP Security Headers Complete Guide: CSP, HSTS, CORS, and More

Master HTTP security headers: Content-Security-Policy, HSTS, X-Frame-Options, CORS configuration, Permissions-Policy, and how to test your security header implementation.

Security Headers: Your Last Line of Defense

Security headers don't prevent vulnerabilities in your code — they limit what attackers can do when vulnerabilities exist. They're the browser-level safety net that catches attacks that slip through your application defenses.

The good news: most security headers are one-liners to implement. The challenge is understanding what each header does, which value is right for your application, and how to test that they're actually working.

Content-Security-Policy (CSP)

The most powerful and complex security header. CSP defines a whitelist of sources for every type of resource the browser can load:

Content-Security-Policy: 
  default-src 'self';
  script-src 'self' https://cdn.example.com 'nonce-{random}';
  style-src 'self' https://fonts.googleapis.com 'unsafe-inline';
  font-src 'self' https://fonts.gstatic.com;
  img-src 'self' data: https:;
  connect-src 'self' https://api.example.com wss://ws.example.com;
  media-src 'none';
  object-src 'none';
  frame-src https://youtube.com;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  upgrade-insecure-requests;
  report-uri https://example.com/csp-reports;

Key directives explained:

Directive What it controls
default-src Fallback for all unspecified resource types
script-src JavaScript sources (most important!)
style-src CSS sources
img-src Image sources
connect-src fetch, XHR, WebSocket destinations
frame-ancestors Who can embed this page (replaces X-Frame-Options)
base-uri Restricts <base> tag (prevents base tag injection)
form-action Where forms can submit
upgrade-insecure-requests Upgrades HTTP subresources to HTTPS

Start with report-only mode:

Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-reports

This reports violations without blocking anything — essential for testing CSP before deployment.

CSP Nonces (Better than unsafe-inline)

// Express middleware generating fresh nonce per request
import crypto from 'crypto'

export function cspMiddleware(req: Request, res: Response, next: NextFunction) {
  const nonce = crypto.randomBytes(16).toString('base64')
  res.locals.nonce = nonce
  
  res.setHeader('Content-Security-Policy', [
    "default-src 'self'",
    `script-src 'self' 'nonce-${nonce}'`,
    `style-src 'self' 'nonce-${nonce}'`,
    "img-src 'self' data: https:",
    "object-src 'none'",
    "base-uri 'self'",
    "frame-ancestors 'none'",
  ].join('; '))
  
  next()
}

// In template:
// <script nonce="<%= nonce %>">...</script>

HTTP Strict Transport Security (HSTS)

Forces browsers to always use HTTPS for your domain:

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
  • max-age=31536000: Browser remembers HTTPS-only for 1 year
  • includeSubDomains: Applies to all subdomains
  • preload: Allows submission to browser preload lists (hardcoded HTTPS)

Deployment strategy — start conservative, increase over time:

# Phase 1: Test with short max-age
Strict-Transport-Security: max-age=300

# Phase 2: Increase to 1 day
Strict-Transport-Security: max-age=86400

# Phase 3: Add subdomains (only if ALL subdomains support HTTPS)
Strict-Transport-Security: max-age=86400; includeSubDomains

# Phase 4: Full year (submit to preload list)
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

Warning: HSTS with preload is difficult to undo. If you can't serve HTTPS from a subdomain, don't use includeSubDomains.

CORS (Cross-Origin Resource Sharing)

CORS controls which domains can make requests to your API from browsers:

// Express CORS middleware
import cors from 'cors'

// ❌ Too permissive for production APIs:
app.use(cors()) // Allows ALL origins

// ✅ Explicit allowlist:
const allowedOrigins = [
  'https://app.example.com',
  'https://admin.example.com',
  process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : null,
].filter(Boolean) as string[]

app.use(cors({
  origin: (origin, callback) => {
    // Allow requests with no origin (curl, Postman, server-to-server)
    if (!origin) return callback(null, true)
    
    if (allowedOrigins.includes(origin)) {
      callback(null, true)
    } else {
      callback(new Error(`CORS blocked: ${origin} not allowed`))
    }
  },
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization', 'X-CSRF-Token'],
  credentials: true,      // Allow cookies
  maxAge: 86400,          // Cache preflight for 24 hours
}))

Understanding preflight requests:

For "complex" requests (non-simple methods, custom headers):

Browser sends OPTIONS request first:
OPTIONS /api/users HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization

Server responds:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST, GET, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400

Then browser sends the actual request.

X-Frame-Options vs frame-ancestors

# Old way — still needed for IE11:
X-Frame-Options: DENY
# or:
X-Frame-Options: SAMEORIGIN

# Modern way (CSP frame-ancestors):
Content-Security-Policy: frame-ancestors 'none'
# or:
Content-Security-Policy: frame-ancestors 'self' https://trusted-dashboard.example.com

Use both for maximum browser compatibility:

X-Frame-Options: DENY
Content-Security-Policy: frame-ancestors 'none'

Permissions-Policy

Controls browser features for your page and iframes:

Permissions-Policy: 
  camera=(),
  microphone=(),
  geolocation=(self),
  payment=(self "https://payment.example.com"),
  usb=(),
  accelerometer=(),
  gyroscope=()
  • camera=(): Disable entirely (empty = deny all)
  • geolocation=(self): Allow for this origin only
  • payment=(self "https://..."): Allow for this origin + specific third party

Additional Security Headers

# Prevent MIME type sniffing
X-Content-Type-Options: nosniff

# Referrer information control
Referrer-Policy: strict-origin-when-cross-origin
# strict-origin-when-cross-origin: Send origin only for cross-origin HTTPS→HTTPS,
# full URL for same-origin, nothing for HTTPS→HTTP

# Cross-Origin policies (advanced)
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-origin
# These three together enable SharedArrayBuffer and high-resolution timers
# Required for: WebAssembly threads, Atomics, performance.measureUserAgentSpecificMemory()

Complete Header Configuration

Express.js with Helmet:

import helmet from 'helmet'

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`],
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", "data:", "https:"],
      connectSrc: ["'self'", "https://api.example.com"],
      frameSrc: ["'none'"],
      objectSrc: ["'none'"],
      upgradeInsecureRequests: [],
    },
  },
  hsts: {
    maxAge: 31536000,
    includeSubDomains: true,
    preload: true,
  },
  referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
  permittedCrossDomainPolicies: false,
  crossOriginEmbedderPolicy: false, // Only enable if needed
}))

Next.js (next.config.js):

const securityHeaders = [
  { key: 'X-DNS-Prefetch-Control', value: 'on' },
  { key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains; preload' },
  { key: 'X-Content-Type-Options', value: 'nosniff' },
  { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
  { key: 'X-Frame-Options', value: 'DENY' },
  { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(self)' },
]

module.exports = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: securityHeaders,
      },
    ]
  },
}

Testing Security Headers

# Check headers with curl:
curl -I https://example.com

# Or check a specific header:
curl -sI https://example.com | grep -i "content-security-policy"

# Online tools:
# https://securityheaders.com — grades your headers
# https://observatory.mozilla.org — comprehensive security analysis
# https://csp-evaluator.withgoogle.com — CSP-specific evaluation

# Test CSP locally with report-only + logging:
# Set: Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report
# Add route to log violations:
app.post('/csp-report', express.json({ type: 'application/csp-report' }), (req, res) => {
  console.log('CSP Violation:', JSON.stringify(req.body, null, 2))
  res.status(204).send()
})

Security headers are cheap to add and meaningfully reduce your attack surface. The cost of implementing them — a few lines of configuration — is far less than dealing with a successful XSS attack that could have been stopped by a proper CSP.

→ Look up HTTP status codes and their meanings with the HTTP Status Codes reference.