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 response metadata that tells browsers how to handle your content: cheap server-side defense that costs nothing in 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.

The OWASP Secure Headers Project keeps a dedicated reference for security headers because they’re useful, easy to implement, and frequently forgotten during deployments. A server missing these headers isn’t immediately vulnerable, but it’s considerably less resilient against common web attacks. For broader application hardening, see the security best practices guide.

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

Skip these if your consumers are all machine-to-machine or native apps — headers are browser instructions and a non-browser client ignores them.

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:"],
    },
  },
  strictTransportSecurity: {
    maxAge: 63072000, // 2 years, per OWASP guidance
    includeSubDomains: true,
  },
}));

// Helmet's defaults already set COOP, COEP, CORP, Origin-Agent-Cluster
// and X-Content-Type-Options, so you only customize what differs.

Nginx Configuration

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

    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" 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;
    add_header Cross-Origin-Opener-Policy "same-origin" always;
    add_header Cross-Origin-Resource-Policy "same-origin" always;
    add_header X-Permitted-Cross-Domain-Policies "none" always;
}

FastAPI (Python)

from fastapi import FastAPI
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): Forces the browser onto HTTPS for your domain. Prevents SSL stripping attacks where a man-in-the-middle downgrades the connection to HTTP. Keep preload out of the config until every subdomain serves HTTPS; once you’re on the preload list, removal is slow.
  • Content-Security-Policy (CSP): An allowlist covering where scripts, styles, images, and other resources may load from. Start tight: a strict CSP stops inline scripts and unknown domains, so injected markup can’t phone home even if an attacker gets it onto the page. For a JSON-only API, default-src 'none' is enough.
  • X-Frame-Options: Keeps your site out of <iframe>s on other domains — clickjacking needs the embed: an invisible frame puts your buttons under the attacker’s overlay. CSP’s frame-ancestors directive covers the same ground with more control.
  • X-Content-Type-Options: With nosniff, the browser refuses to reinterpret a file as a different MIME type than the server declared, the gap that turns a user-uploaded .txt into executable JavaScript.
  • Referrer-Policy: Decides how much of the URL leaks into the Referer header on outgoing requests. With strict-origin-when-cross-origin, full URLs (including tokens in query strings) only travel same-origin.
  • Permissions-Policy: Shuts down browser features you don’t need — an empty allowlist kills camera, microphone, geolocation, and payment for a few bytes per response.
  • Cross-Origin-Opener/Resource/Embedder-Policy: The COOP/COEP/CORP trio isolates your browsing context and declares which origins may embed your resources, blocking Spectre-style cross-origin data leaks.
  • Cache-Control: Not a “security header” in the strict sense, but no-store on responses that carry tokens or personal data keeps them out of shared and browser caches.

Here’s the trip a header makes from your server to the browser’s policy engine:

Mermaid flowchart LR diagram

Variants

HeaderAttack PreventedRequired?Browser Support
HSTSSSL strippingYesUniversal
CSPXSS, data injectionYesUniversal
X-Frame-OptionsClickjackingYesUniversal
X-Content-Type-OptionsMIME sniffingYesUniversal
Referrer-PolicyInformation leakageRecommendedUniversal
Permissions-PolicyFeature abuseRecommendedModern
Cross-Origin-Opener-PolicyContext leaks (Spectre)RecommendedModern
Cross-Origin-Resource-PolicyUnauthorized embeddingRecommendedModern
Clear-Site-DataStale session dataLogout responsesModern
Cache-Control: no-storeCached sensitive dataYes, for APIsUniversal

Skip the deprecated ones: X-XSS-Protection (removed from browsers, enabled vulnerabilities) and Feature-Policy (the old name for Permissions-Policy).

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.
  • Treat HSTS preload as a separate decision: once every subdomain serves HTTPS, add the preload directive and submit to hstspreload.org. Don’t enable it by default; removal is slow and breaks any subdomain that still needs HTTP.
  • 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 securityheaders.com 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 don’t support this value. Use SAMEORIGIN or DENY instead.
  • Enabling unsafe-inline for scripts in CSP: it switches off the XSS protection CSP exists for. Use nonces or hashes when inline scripts are unavoidable.
  • Forgetting API endpoints: teams configure the headers on HTML routes and leave JSON responses bare. Apply them globally; an attacker doesn’t care which content type they hit.
  • Setting HSTS without HTTPS ready: if part of your site still serves HTTP, HSTS breaks it for anyone who already visited the HTTPS version.

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(63072000)
                    .includeSubDomains(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=63072000; includeSubDomains');
    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

See Also

Frequently Asked Questions

Do security headers protect APIs consumed by mobile apps?

Not really — they're browser instructions; a native app's HTTP client ignores CSP or X-Frame-Options entirely. For API-to-app traffic, your defenses are authentication, input validation, and TLS.

Can I set security headers in a CDN like Cloudflare?

Yes — often the best option when you can't touch the origin. Cloudflare Transform Rules and AWS CloudFront Functions inject headers at the edge, handy for static sites or legacy apps you can't redeploy.

What is the difference between CSP and CORS?

They solve different problems. CSP tells the browser which resources it may load while rendering your page; CORS tells it which other origins may call your API. Most apps need both.

Should I use report-uri in CSP?

It's on the way out in favor of report-to and the Reporting API, though browsers still honor it for now. During rollout, pair either one with Content-Security-Policy-Report-Only to collect violations without blocking anything; enforce once the policy is clean.

Do I need CSP on a JSON-only API?

A minimal one. default-src 'none' plus frame-ancestors 'none' costs a few bytes and shuts down resource loading and framing on responses that never use browser features.

Should headers be set in the proxy or the application?

Prefer the edge. Setting them in Nginx, a CDN, or an API gateway covers every response uniformly, including error pages your app might not control. Keep app-level middleware as the fallback for headers that depend on route context, like CSP nonces.