StackPractices
beginner By Mathias Paulenko

Secure APIs with HTTP Security Headers

How to configure essential security headers like HSTS, CSP, and X-Frame-Options to protect APIs and web applications from common attacks.

Topics: security

Overview

HTTP security headers are a lightweight, server-side defense layer that instructs browsers how to handle your content. They require no changes to application code and protect against entire classes of attacks: clickjacking via X-Frame-Options, cross-site scripting via Content-Security-Policy, protocol downgrade attacks via Strict-Transport-Security, and MIME-type sniffing via X-Content-Type-Options.

OWASP maintains a dedicated cheat sheet for security headers because they are useful, easy to implement, and frequently forgotten during deployments. A server missing these headers is not immediately vulnerable, but it is considerably less resilient against common web attacks.

When to Use

Use this recipe when:

  • Launching a new web application or API to production
  • Conducting security audits or penetration tests
  • Hardening existing applications after a security review
  • Configuring reverse proxies (Nginx, Apache, CloudFront, Cloudflare)
  • Building middleware for Express, FastAPI, or Spring Boot applications

Solution

Express.js Middleware

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

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "https://trusted-cdn.com"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", "data:", "https:"],
    },
  },
  hsts: {
    maxAge: 31536000,
    includeSubDomains: true,
    preload: true,
  },
}));

Nginx Configuration

server {
    listen 443 ssl;
    server_name api.example.com;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    add_header X-Frame-Options "DENY" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy "geolocation=(), microphone=()" always;
}

FastAPI (Python)

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.base import BaseHTTPMiddleware

class SecurityHeadersMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        response = await call_next(request)
        response.headers["X-Content-Type-Options"] = "nosniff"
        response.headers["X-Frame-Options"] = "DENY"
        response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
        return response

app = FastAPI()
app.add_middleware(SecurityHeadersMiddleware)

Explanation

  • Strict-Transport-Security (HSTS): Tells browsers to always use HTTPS for your domain. Prevents SSL stripping attacks where a man-in-the-downgrades the connection to HTTP.
  • Content-Security-Policy (CSP): Restricts where scripts, styles, images, and other resources can load from. A strict CSP blocks inline scripts and unauthorized external domains, neutralizing XSS even if an attacker injects markup.
  • X-Frame-Options: Prevents your site from being embedded in an <iframe> on another domain. This blocks clickjacking attacks where attackers overlay invisible frames to trick users into clicking malicious elements.
  • X-Content-Type-Options: Setting nosniff prevents browsers from interpreting files as a different MIME type than declared. This mitigates attacks where a user-uploaded .txt file is executed as JavaScript.

Variants

HeaderAttack PreventedRequired?Browser Support
HSTSSSL strippingYesUniversal
CSPXSS, data injectionYesUniversal
X-Frame-OptionsClickjackingYesUniversal
X-Content-Type-OptionsMIME sniffingYesUniversal
Referrer-PolicyInformation leakageRecommendedUniversal
Permissions-PolicyFeature abuseRecommendedModern

What works

  • Use Helmet as a baseline: the Helmet middleware for Express sets sensible defaults for all major headers with a single line of code.
  • Start with a restrictive CSP and relax gradually: begin with default-src 'self' and add domains only when functionality breaks. A too-permissive CSP is almost worthless.
  • Submit to HSTS preload lists: after running HSTS for a few weeks without issues, submit your domain to Chrome’s preload list so browsers enforce HTTPS before the first visit.
  • Include headers on all responses: error pages (404, 500) and API responses should include the same headers as HTML pages. Attackers target error pages too.
  • Test with securityheader.io or Mozilla Observatory: these tools scan your site and grade your header configuration with specific remediation steps.

Common Mistakes

  • Using ALLOW-FROM in X-Frame-Options: modern browsers do not support this value. Use SAMEORIGIN or DENY instead.
  • Enabling unsafe-inline for scripts in CSP: this disables CSP’s XSS protection. Use nonces or hashes if inline scripts are unavoidable.
  • Forgetting API endpoints: security headers are often configured for HTML routes but omitted from JSON API responses. Apply them globally.
  • Setting HSTS without HTTPS ready: if your site still serves HTTP traffic, HSTS will break it for users who have visited the HTTPS version before.

Advanced Solutions

Spring Boot security headers configuration

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.web.SecurityFilterChain;

@Configuration
public class SecurityHeadersConfig {

    @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 -> {})
                .frameOptions(frame -> frame.deny())
                .referrerPolicy(referrer -> referrer.policy(
                    org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN
                ))
                .permissionsPolicy(permissions -> permissions.policy(
                    "geolocation=(), microphone=(), camera=(), payment=()"
                ))
            );

        return http.build();
    }
}

Cloudflare Workers header injection

Inject security headers at the edge without touching origin infrastructure:

export default {
  async fetch(request, env) {
    const response = await fetch(request);

    // Clone the response so we can modify headers
    const newResponse = new Response(response.body, response);

    // Security headers
    newResponse.headers.set('Strict-Transport-Security',
      'max-age=31536000; includeSubDomains; preload');
    newResponse.headers.set('X-Content-Type-Options', 'nosniff');
    newResponse.headers.set('X-Frame-Options', 'DENY');
    newResponse.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
    newResponse.headers.set('Permissions-Policy',
      'geolocation=(), microphone=(), camera=()');

    // Only set CSP for HTML responses
    const contentType = response.headers.get('Content-Type') || '';
    if (contentType.includes('text/html')) {
      newResponse.headers.set('Content-Security-Policy',
        "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");
    }

    return newResponse;
  },
};

CORS preflight with security headers

Combine CORS and security headers for cross-origin APIs:

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

const app = express();

// Security headers first
app.use(helmet());

// CORS with specific origin allowlist
const corsOptions = {
  origin: (origin, callback) => {
    const allowedOrigins = [
      'https://app.example.com',
      'https://admin.example.com',
    ];
    if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
  exposedHeaders: ['X-Request-ID', 'X-RateLimit-Remaining'],
  credentials: true,
  maxAge: 86400, // Cache preflight for 24 hours
};

app.use(cors(corsOptions));

// Explicit preflight handler
app.options('*', cors(corsOptions));

// API routes
app.get('/api/health', (req, res) => {
  res.json({ status: 'ok' });
});

Automated header testing with curl

#!/bin/bash
# audit-headers.sh — Check security headers on a URL

URL="${1:-https://example.com}"
REQUIRED_HEADERS=(
  "strict-transport-security"
  "content-security-policy"
  "x-content-type-options"
  "x-frame-options"
  "referrer-policy"
)

echo "Auditing: $URL"
echo "-----------------------------------"

HEADERS=$(curl -sI "$URL")

for header in "${REQUIRED_HEADERS[@]}"; do
  VALUE=$(echo "$HEADERS" | grep -i "^$header:" | sed 's/^[^:]*: *//')
  if [ -z "$VALUE" ]; then
    echo "MISSING: $header"
  else
    echo "OK: $header = $VALUE"
  fi
done

# Check for weak CSP
CSP=$(echo "$HEADERS" | grep -i "^content-security-policy:" | sed 's/^[^:]*: *//')
if echo "$CSP" | grep -q "unsafe-inline"; then
  echo "WARNING: CSP contains 'unsafe-inline'"
fi
if echo "$CSP" | grep -q "unsafe-eval"; then
  echo "WARNING: CSP contains 'unsafe-eval'"
fi

# Check HSTS max-age
HSTS=$(echo "$HEADERS" | grep -i "^strict-transport-security:" | sed 's/^[^:]*: *//')
if echo "$HSTS" | grep -q "max-age=0"; then
  echo "WARNING: HSTS max-age is 0 (disabled)"
fi

Frequently Asked Questions

Do security headers protect APIs consumed by mobile apps?

Most security headers are browser-specific. Mobile native apps using HTTP clients are not affected by CSP or X-Frame-Options. Focus on authentication, input validation, and TLS for API-to-app communication.

Can I set security headers in a CDN like Cloudflare?

Yes. Cloudflare Transform Rules and AWS CloudFront Functions can inject headers at the edge without touching origin code. This is useful for static sites or legacy systems.

What is the difference between CSP and CORS?

CSP controls what resources a browser can load when rendering your page. CORS controls whether other origins can make requests to your API. They are complementary, not substitutes.

Should I use report-uri in CSP?

Yes, during rollout. The report-uri directive sends violation reports to an endpoint without blocking content. This helps you identify legitimate sources you forgot to whitelist before enforcing the policy.