Security Headers Testing: CSP, HSTS, X-Frame-Options and More

HTTP security headers are one of the highest-signal, lowest-effort indicators of how seriously a team takes security. A missing Content-Security-Policy tells you the application has no XSS mitigation layer. A weak HSTS configuration with no includeSubDomains tells you TLS is an afterthought. Headers are easy to add — and trivially easy to miss.

This guide covers how to test every major security header: what it does, what attack it blocks, what a correct value looks like, what a weak value looks like, and how to fix it. We also cover the newer cross-origin isolation headers (COEP, COOP, CORP) and provide implementation examples for every major stack.

How to Test Security Headers

Before diving into individual headers, here is how pentesters actually collect them.

curl

The fastest way to dump all response headers from a target:

# Dump all response headers (follow redirects, suppress body)
curl -sI https://example.com

# Include headers from the final redirect destination
curl -sIL https://example.com

# Show request + response headers verbose
curl -sv https://example.com 2>&1 | grep -E "^[<>]"

# Check headers after POST (some apps differ by method)
curl -sI -X POST https://example.com/api/login

Browser DevTools

Open DevTools (F12) → Network tab → reload the page → click the document request → Headers tab. Look at the Response Headers section. Any security header that is missing simply will not appear — absence is the finding.

Nikto

# Nikto will flag missing headers as findings
nikto -h https://example.com -ssl

# Save output for review
nikto -h https://example.com -output report.txt -Format txt

Nmap http-headers script

nmap -p 443 --script http-headers example.com

Online scanners

securityheaders.com gives a quick letter grade and highlights every missing or misconfigured header. Useful for a first pass, but a scanner cannot tell you whether the CSP is logically sound — only that it exists.


Security Headers Reference Table

Header Purpose Good Value Common Mistakes
Content-Security-Policy Controls which resources the browser may load; primary XSS mitigation layer default-src 'none'; script-src 'nonce-{random}' unsafe-inline, unsafe-eval, wildcard hosts, missing entirely
Strict-Transport-Security Forces HTTPS for a defined period; prevents SSL stripping attacks max-age=63072000; includeSubDomains; preload Short max-age, missing includeSubDomains, sent over HTTP
X-Frame-Options Prevents the page from being embedded in a frame; clickjacking defense DENY or SAMEORIGIN ALLOWALL, missing header, CSP frame-ancestors not set as replacement
X-Content-Type-Options Stops browsers from MIME-sniffing; prevents content-type confusion attacks nosniff Missing entirely; only one valid value exists
Referrer-Policy Controls how much referrer information is sent to third parties strict-origin-when-cross-origin unsafe-url leaks full URLs including tokens; missing means browser default (usually full URL)
Permissions-Policy Restricts browser feature access (camera, mic, geolocation, payment) camera=(), microphone=(), geolocation=() Not set at all; old Feature-Policy syntax still used
Cross-Origin-Embedder-Policy Prevents loading cross-origin resources without explicit permission; required for SharedArrayBuffer require-corp Not set; breaks CDN-loaded resources if set without CORP on assets
Cross-Origin-Opener-Policy Isolates the browsing context; prevents cross-origin window attacks same-origin Not set; breaks OAuth popups if set too aggressively
Cross-Origin-Resource-Policy Prevents other origins from reading this resource via no-CORS requests same-origin or same-site Not set on API responses; cross-origin defeats the purpose

Deep Dive: Content-Security-Policy

CSP is the most complex and most impactful security header. It tells the browser exactly which sources are allowed to load scripts, styles, images, fonts, frames, and connections. A well-configured CSP makes XSS exploitation dramatically harder even when an injection point exists.

Key Directives

What Makes a CSP Weak

'unsafe-inline' in script-src disables most XSS protection. It allows any inline <script> tag, inline event handlers, and javascript: URLs. If you see this, CSP provides minimal protection against XSS.

'unsafe-eval' allows eval(), Function(), setTimeout('string'), and similar constructs. An attacker who can influence a string passed to these functions achieves code execution even without unsafe-inline.

Wildcard hosts like script-src * or script-src https: allow loading scripts from any HTTPS domain. This is nearly equivalent to no CSP — an attacker just needs to find any XSS on any HTTPS site (including their own) to bypass it.

Allowlisted CDN domains can be bypassed via JSONP endpoints, AngularJS template injection, or other JavaScript gadgets hosted on those domains. A CSP that includes script-src https://ajax.googleapis.com can be bypassed with <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.0.1/angular.min.js"> followed by an Angular template injection.

Nonces and Hashes: The Right Approach

Instead of allowlisting script sources, generate a cryptographic nonce per response and add it to every legitimate script tag:

Content-Security-Policy: script-src 'nonce-r4nd0mV4lu3' 'strict-dynamic'

<!-- Only this tag executes; injected scripts without the nonce are blocked -->
<script nonce="r4nd0mV4lu3">
    // application code
</script>

The nonce must be cryptographically random (minimum 128 bits of entropy) and regenerated on every response. If an attacker can predict or read the nonce, the protection collapses.

'strict-dynamic' allows scripts loaded by a nonce-trusted script to also execute. This is needed when your entry-point script dynamically creates additional script elements. Without it, dynamically inserted scripts are blocked — which breaks most modern single-page applications.

For static assets where you cannot inject nonces, use a hash instead:

Content-Security-Policy: script-src 'sha256-abc123...base64hash...'

The browser computes the SHA-256 hash of the script body and only executes it if it matches. This works well for inline scripts with fixed content.

A Strict CSP for Production

Content-Security-Policy:
  default-src 'none';
  script-src 'nonce-{random}' 'strict-dynamic';
  style-src 'nonce-{random}';
  img-src 'self' data: https:;
  font-src 'self';
  connect-src 'self' https://api.example.com;
  frame-ancestors 'none';
  form-action 'self';
  base-uri 'self';
  object-src 'none';
  upgrade-insecure-requests;

CSP Bypass Techniques

When testing a CSP, run through these bypass classes:


Deep Dive: HTTP Strict Transport Security (HSTS)

HSTS tells browsers to always connect over HTTPS, even if the user types http:// or clicks an HTTP link. Without HSTS, an attacker on the same network can perform an SSL stripping attack — intercepting the initial HTTP request before the redirect to HTTPS and serving a plain HTTP version of the site indefinitely.

Directive Breakdown

Strict-Transport-Security: max-age=63072000; includeSubDomains; preload

Common Weaknesses

Testing HSTS

# Check HSTS header presence and value
curl -sI https://example.com | grep -i strict-transport

# Verify HTTP response does NOT include HSTS (correct) and redirects
curl -sI http://example.com | grep -E "location|strict"

# Check preload list membership
# Visit: https://hstspreload.org/?domain=example.com

HSTS Bypasses

HSTS can be stripped if the attacker can intercept the user's very first visit to a site (before the HSTS header is received and cached). This is the "trust on first use" problem. Preloading eliminates it entirely. Another bypass: if the attacker controls a non-HSTS subdomain (because includeSubDomains is missing), they can set a cookie with Domain=.example.com from the HTTP subdomain to perform cookie injection attacks against the HTTPS main domain.


X-Frame-Options

X-Frame-Options prevents the page from being loaded inside an <iframe>, <frame>, or <object>. Without this header, an attacker can embed your login page or payment form in a transparent overlay on a malicious site and trick users into clicking on it — a clickjacking attack.

# Deny all framing
X-Frame-Options: DENY

# Allow framing only from the same origin
X-Frame-Options: SAMEORIGIN

The deprecated ALLOW-FROM uri directive is not supported by modern browsers and should not be used. Use CSP frame-ancestors instead for fine-grained allowlisting:

# CSP replacement — more flexible and better supported
Content-Security-Policy: frame-ancestors 'self' https://trusted-partner.com

Note that CSP frame-ancestors takes precedence over X-Frame-Options in browsers that support CSP. Send both for maximum compatibility with older browsers.

Testing

curl -sI https://example.com | grep -i "x-frame-options\|frame-ancestors"

# Try to embed in an iframe — if the header is correct, this will be blocked
<iframe src="https://example.com"></iframe>

X-Content-Type-Options

This header has exactly one valid value: nosniff. It tells the browser not to MIME-sniff the response — that is, not to guess the content type based on the response body if the server says Content-Type: text/plain but the body looks like JavaScript.

Without nosniff, an attacker can upload a file with a benign content type (e.g., image/jpeg) but embed JavaScript in it. If the browser sniffs the content and decides it looks like script, it may execute it.

X-Content-Type-Options: nosniff

This header should be present on every response, but it is especially critical on responses that serve user-uploaded content.


Referrer-Policy

When a user clicks a link, the browser sends a Referer header to the destination containing the URL of the page the user came from. Without a policy, full URLs including query strings and path parameters are leaked to third parties — which can include session tokens, search terms, or user identifiers embedded in URLs.

# Recommended: send origin only on same-site requests, origin only on cross-origin HTTPS
Referrer-Policy: strict-origin-when-cross-origin

# Most restrictive: never send referrer
Referrer-Policy: no-referrer

# Dangerous: sends full URL to any destination
Referrer-Policy: unsafe-url

Test for sensitive data in referrer headers by checking third-party analytics requests in DevTools. If the Referer header on a request to Google Analytics contains a reset token or a user ID, that data is being leaked.


Permissions-Policy

Permissions-Policy (formerly Feature-Policy) restricts which browser APIs are available to the page and any embedded iframes. If your application does not use the camera or microphone, there is no reason for embedded third-party scripts to have access to them.

# Disable all features not in use
Permissions-Policy:
  camera=(),
  microphone=(),
  geolocation=(),
  payment=(),
  usb=(),
  bluetooth=(),
  accelerometer=(),
  gyroscope=(),
  magnetometer=(),
  picture-in-picture=()

For applications that do legitimately use some APIs, grant access only to the specific origins that need it:

# Allow geolocation only for same-origin pages, not iframes
Permissions-Policy: geolocation=(self)

# Allow payment API for specific trusted origin
Permissions-Policy: payment=(self "https://checkout.stripe.com")

Test by checking DevTools → Application → Permissions Policy (Chrome), which shows the effective policy and which features are allowed or blocked per frame.


Cross-Origin Headers: COEP, COOP, CORP

These three headers work together to enable cross-origin isolation — a security state required to use high-resolution timers, SharedArrayBuffer, and other powerful APIs that were temporarily disabled after Spectre-class CPU vulnerabilities were disclosed. They are also valuable independently as defense against cross-origin data leaks.

Cross-Origin-Embedder-Policy (COEP)

Cross-Origin-Embedder-Policy: require-corp

With require-corp, the page can only load cross-origin resources that explicitly opt in via a Cross-Origin-Resource-Policy header. This prevents the page from loading cross-origin resources without the resource owner's consent — closing a class of side-channel attacks where timing differences during resource loading could be used to infer private data.

Deployment caveat: Setting COEP will break any cross-origin resource that does not include a Cross-Origin-Resource-Policy header — including many CDN assets, third-party images, and embeds. Audit all cross-origin dependencies before enabling. Use require-corp only after ensuring all resources are opted in, or use credentialless as a transition mode.

Cross-Origin-Opener-Policy (COOP)

Cross-Origin-Opener-Policy: same-origin

COOP controls which windows can share a browsing context group with your page. With same-origin, only windows from the same origin can access your window object via window.opener or window.open(). This prevents cross-origin windows from probing your DOM or timing operations on your page using shared browsing context access.

OAuth popup caveat: Many OAuth flows use window.open() and window.opener.postMessage() to return tokens to the parent window. If the OAuth provider and your application are on different origins (they usually are), setting COOP to same-origin will break the flow. Use same-origin-allow-popups to allow outbound popups while still protecting inbound ones:

Cross-Origin-Opener-Policy: same-origin-allow-popups

Cross-Origin-Resource-Policy (CORP)

# Restrict this resource to same-origin requests only
Cross-Origin-Resource-Policy: same-origin

# Allow same-site (includes subdomains)
Cross-Origin-Resource-Policy: same-site

# Explicit opt-in for cross-origin loading (required by COEP)
Cross-Origin-Resource-Policy: cross-origin

CORP is set on resources (images, scripts, API responses), not on the loading page. It prevents cross-origin no-CORS requests from reading the response body. Without CORP, an attacker page can load your API endpoint as an image source and, while they cannot read the response body in JavaScript, the request is still made with credentials — enabling CSRF-equivalent side-channel attacks.

Achieving Cross-Origin Isolation

To enable cross-origin isolation (required for SharedArrayBuffer), set both COEP and COOP:

Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin

Verify isolation status in the browser console:

crossOriginIsolated // should return true

Implementation Examples

Nginx

server {
    # HSTS
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

    # CSP — adjust sources for your application
    add_header Content-Security-Policy "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; form-action 'self'; base-uri 'self'; object-src 'none';" always;

    # Framing
    add_header X-Frame-Options "DENY" always;

    # MIME sniffing
    add_header X-Content-Type-Options "nosniff" always;

    # Referrer
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    # Permissions
    add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;

    # Cross-origin isolation (if required)
    add_header Cross-Origin-Embedder-Policy "require-corp" always;
    add_header Cross-Origin-Opener-Policy "same-origin" always;
    add_header Cross-Origin-Resource-Policy "same-origin" always;
}

Apache (.htaccess)

<IfModule mod_headers.c>
    Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
    Header always set Content-Security-Policy "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; form-action 'self'; base-uri 'self'; object-src 'none';"
    Header always set X-Frame-Options "DENY"
    Header always set X-Content-Type-Options "nosniff"
    Header always set Referrer-Policy "strict-origin-when-cross-origin"
    Header always set Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()"
</IfModule>

Express.js (Node.js)

Use the helmet middleware for sane defaults with easy customization:

import helmet from 'helmet';

app.use(helmet({
    contentSecurityPolicy: {
        directives: {
            defaultSrc: ["'none'"],
            scriptSrc: ["'self'"],
            styleSrc: ["'self'"],
            imgSrc: ["'self'", "data:"],
            connectSrc: ["'self'"],
            fontSrc: ["'self'"],
            objectSrc: ["'none'"],
            frameAncestors: ["'none'"],
            formAction: ["'self'"],
            baseUri: ["'self'"],
            upgradeInsecureRequests: [],
        },
    },
    hsts: {
        maxAge: 63072000,
        includeSubDomains: true,
        preload: true,
    },
    frameguard: { action: 'deny' },
    noSniff: true,
    referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
    permittedCrossDomainPolicies: false,
}));

// Permissions-Policy (not yet in helmet — add manually)
app.use((req, res, next) => {
    res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()');
    next();
});

Django (Python)

# settings.py

# HSTS
SECURE_HSTS_SECONDS = 63072000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True

# Other security settings
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_BROWSER_XSS_FILTER = True  # Legacy; adds X-XSS-Protection
X_FRAME_OPTIONS = 'DENY'
SECURE_SSL_REDIRECT = True  # Redirect HTTP to HTTPS

# CSP — use django-csp package
CSP_DEFAULT_SRC = ("'none'",)
CSP_SCRIPT_SRC = ("'self'",)
CSP_STYLE_SRC = ("'self'",)
CSP_IMG_SRC = ("'self'", "data:")
CSP_CONNECT_SRC = ("'self'",)
CSP_FONT_SRC = ("'self'",)
CSP_OBJECT_SRC = ("'none'",)
CSP_FRAME_ANCESTORS = ("'none'",)
CSP_FORM_ACTION = ("'self'",)
CSP_BASE_URI = ("'self'",)

# Custom headers middleware
MIDDLEWARE = [
    'csp.middleware.CSPMiddleware',
    # ... other middleware
]

FastAPI / Flask (Python)

# FastAPI
from fastapi import FastAPI, Response
from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware

app = FastAPI()

@app.middleware("http")
async def add_security_headers(request, call_next):
    response = await call_next(request)
    response.headers["Strict-Transport-Security"] = "max-age=63072000; includeSubDomains; preload"
    response.headers["X-Content-Type-Options"] = "nosniff"
    response.headers["X-Frame-Options"] = "DENY"
    response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
    response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(), payment=()"
    response.headers["Content-Security-Policy"] = (
        "default-src 'none'; script-src 'self'; style-src 'self'; "
        "img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; "
        "form-action 'self'; base-uri 'self'; object-src 'none';"
    )
    return response

Next.js

// next.config.js
const securityHeaders = [
    {
        key: 'Strict-Transport-Security',
        value: 'max-age=63072000; includeSubDomains; preload',
    },
    {
        key: 'X-Frame-Options',
        value: 'DENY',
    },
    {
        key: 'X-Content-Type-Options',
        value: 'nosniff',
    },
    {
        key: 'Referrer-Policy',
        value: 'strict-origin-when-cross-origin',
    },
    {
        key: 'Permissions-Policy',
        value: 'camera=(), microphone=(), geolocation=(), payment=()',
    },
    {
        key: 'Content-Security-Policy',
        value: [
            "default-src 'none'",
            "script-src 'self' 'nonce-${nonce}'",
            "style-src 'self'",
            "img-src 'self' data:",
            "connect-src 'self'",
            "frame-ancestors 'none'",
            "form-action 'self'",
            "base-uri 'self'",
            "object-src 'none'",
        ].join('; '),
    },
];

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

Security Headers Testing Checklist

Use this checklist on every target. Each item is a distinct finding if failed.

Collection

Content-Security-Policy

HSTS

X-Frame-Options / frame-ancestors

X-Content-Type-Options

Referrer-Policy

Permissions-Policy

Cross-Origin Headers

Ironimo automatically audits HTTP security headers on every scan — checking CSP configuration, HSTS setup, missing security headers, and unsafe directives using the same tools professional pentesters use.

Get a complete picture of your security posture on every deployment. Schedule recurring scans so header regressions get caught before they reach production.

Start free scan
← Back to blog