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
- default-src — fallback for any directive not explicitly set. Start with
'none'and add only what you need. - script-src — controls JavaScript sources. This is where most bypasses live.
- style-src — controls CSS sources. Inline styles can be used for CSS injection / data exfiltration via attribute selectors.
- connect-src — controls fetch, XHR, WebSocket destinations. An attacker who injects JS but cannot exfiltrate data is severely limited.
- frame-ancestors — supersedes
X-Frame-Options. Controls which origins can embed this page. - form-action — restricts where forms may submit. Often missed, enabling form hijacking attacks.
- base-uri — restricts
<base>tag targets. Without this, an injected<base href="https://evil.com">turns relative script URLs into attacker-controlled sources. - object-src — controls plugins (Flash, Java applets). Set to
'none'in almost all cases. - upgrade-insecure-requests — instructs the browser to upgrade HTTP sub-requests to HTTPS. Useful during migration but not a substitute for HSTS.
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:
- JSONP bypass — check if any allowlisted domain hosts a JSONP endpoint.
?callback=alertturns it into executable code. - AngularJS template injection — if AngularJS is loaded from an allowlisted domain,
{{constructor.constructor('alert(1)')()}}executes without needingscript-srcto be satisfied. - open redirect on allowlisted domain — combine an open redirect with a script load to redirect to an attacker-controlled script source.
- SVG / data URI —
img-src data:plusdefault-src 'unsafe-inline'can enable SVG-based XSS in some browser versions. - Subdomain wildcard —
script-src *.example.comlets any subdomain serve scripts. If any subdomain is compromised or misconfigured, the CSP is defeated. - Missing
object-src— even a strictscript-srccan be bypassed via Flash or Java plugins ifobject-srcis not explicitly set to'none'.
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
- max-age — how long (in seconds) the browser remembers to use HTTPS. 63072000 = 2 years. HSTS preload list requires a minimum of 31536000 (1 year).
- includeSubDomains — applies the policy to all subdomains. Without this, an attacker can strip HTTPS on
api.example.comeven ifexample.comitself is protected. - preload — signals intent to be added to the browser-shipped HSTS preload list. Once on the list, browsers enforce HTTPS before even attempting a connection — no first-visit vulnerability.
Common Weaknesses
- Short
max-age— a value ofmax-age=300(5 minutes) means the protection expires quickly. An attacker who can strip HTTPS once resets the timer. - HSTS served over HTTP — browsers silently ignore HSTS headers served over plain HTTP. The header must come from an HTTPS response.
- Missing
includeSubDomains— the most commonly missed directive. Test all subdomains independently. - No preload — HSTS only protects returning visitors. The very first visit to a site is still vulnerable unless it is on the preload list.
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
- Collect response headers from at least three distinct pages: homepage, authenticated page, API endpoint
- Test both GET and POST requests — some frameworks apply headers inconsistently by method
- Check headers on HTTP (port 80) as well as HTTPS — some servers only apply headers to HTTPS responses
- Check 4xx and 5xx error responses — frameworks often return error pages without the application's headers
- Check redirect (3xx) responses — HSTS and CSP should be present even on redirect responses
Content-Security-Policy
- Header is present on all HTML responses
default-srcis not*and not missingscript-srcdoes not contain'unsafe-inline'script-srcdoes not contain'unsafe-eval'- No wildcard host in
script-src(e.g.,https:or*) object-srcis explicitly set to'none'frame-ancestorsis set (prefer over X-Frame-Options)base-uriis set to'self'or'none'form-actionis explicitly set- Nonces or hashes used for inline scripts (if any)
- No JSONP endpoints on allowlisted domains
- CSP report-uri or report-to configured for violation monitoring
HSTS
- Header present on all HTTPS responses
max-age≥ 31536000 (1 year)includeSubDomainsdirective presentpreloaddirective present (and domain submitted to preload list)- Header absent on HTTP responses (correct behavior)
X-Frame-Options / frame-ancestors
- Either
X-Frame-Options: DENY/SAMEORIGINpresent, or CSPframe-ancestorsset ALLOW-FROMnot used (unsupported in modern browsers)
X-Content-Type-Options
nosniffpresent on all responses, especially on responses serving user-uploaded content
Referrer-Policy
- Policy is set and not
unsafe-url - Verify no sensitive data (tokens, IDs) appears in
Refererheaders sent to third-party analytics
Permissions-Policy
- Header present
- Camera, microphone, geolocation disabled unless the application requires them
- Payment disabled unless the application processes payments directly
Cross-Origin Headers
- CORP set on API responses and sensitive resources
- COOP and COEP set if the application uses SharedArrayBuffer or requires cross-origin isolation
crossOriginIsolatedistruein the browser console where required
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