Security headers are the cheapest security control you will ever ship. A handful of lines in a server config tells every browser that visits your site to refuse inline script injection, never downgrade to plain HTTP, never let another site frame your login page, and never leak full URLs to third parties.
This guide walks through every header that matters in 2026, what each one really protects against (and what it doesn't), the values the standards bodies recommend, and the traps that catch experienced teams. Then it gets concrete: copy-paste configurations for Next.js, Apache and the AEM Dispatcher, the AEM as a Cloud Service CDN, and Nginx, plus how to test the result.
If you run AEM, pair this with the AEM Dispatcher guide (where most of these headers get set) and the AEM as a Cloud Service guide. If you're on Next.js, see Next.js App Router for AEM developers. And to check any live URL as you read, use the free Security Headers Auditor.
Why security headers matter
Response headers are instructions from your server to the browser. Security headers are the subset that switch on protections the browser would otherwise leave off for backwards compatibility.
What they protect against
| Threat | Primary header(s) |
|---|---|
| Cross-site scripting (XSS) and content injection | Content-Security-Policy |
| Protocol downgrade, SSL stripping, cookie hijacking on HTTP | Strict-Transport-Security |
| Clickjacking | CSP frame-ancestors, X-Frame-Options |
| MIME-confusion attacks (text served as script or HTML) | X-Content-Type-Options: nosniff |
| URL leakage to third parties | Referrer-Policy |
| Abuse of powerful browser features (camera, geolocation) | Permissions-Policy |
| Cross-window attacks and side-channel (Spectre-class) leaks | Cross-Origin-Opener-Policy, Cross-Origin-Embedder-Policy, Cross-Origin-Resource-Policy |
| Session theft and cross-site request forgery | Cookie attributes: Secure, HttpOnly, SameSite, prefixes |
| Sensitive data persisting in caches | Cache-Control: no-store |
What they don't protect against
- They do not fix vulnerabilities. MDN puts it plainly: CSP is not a substitute for input sanitization. It is defense in depth that limits the blast radius when your escaping fails.
- They do nothing server-side. SQL injection, broken access control, SSRF, and leaked credentials are untouched by any response header.
- They only protect browsers that honor them. A
curlscript, a bot, or an attacker's own HTTP client ignores every one of them. - Most of them only matter on documents. OWASP notes that CSP and
X-Frame-Optionsare largely meaningless on a JSON API response that is never rendered.
Content-Security-Policy (CSP) in depth
CSP is the most powerful header and the hardest to get right. A policy is a list of directives separated by semicolons; each directive names a resource type and the sources allowed for it.
Content-Security-Policy: default-src 'self'; img-src 'self' https://images.example.com; object-src 'none'The directives you'll actually use
| Directive | Controls | Falls back to default-src? |
|---|---|---|
default-src | Fallback for every fetch directive not set explicitly | — |
script-src | JavaScript: external files, inline scripts, event handlers, eval | Yes |
style-src | Stylesheets, inline style elements and attributes | Yes |
img-src | Images and favicons | Yes |
connect-src | fetch, XHR, WebSocket, EventSource, sendBeacon | Yes |
font-src, media-src, frame-src, worker-src, manifest-src | Their respective resource types | Yes |
object-src | object and embed plugins | Yes |
frame-ancestors | Which sites may embed this page | No |
base-uri | Allowed values for the document's base element | No |
form-action | Where forms may submit | No |
upgrade-insecure-requests | Rewrites http:// subresource URLs to https:// | n/a |
The fallback column is the single most important thing on that table. frame-ancestors, base-uri, and form-action do not inherit from default-src. MDN states it explicitly for frame-ancestors: a policy with default-src 'none' still allows the page to be embedded by anyone. If you want those protections, you must write them out.
object-src 'none'— always set it. Plugins are dead, andobject/embedwere a classic script-injection vector.base-uri 'none'(or'self') — without it, an injectedbasetag can re-point every relative script URL on the page to an attacker's host, which bypasses a nonce-based policy.form-action 'self'— stops injected markup from exfiltrating data through a form posted elsewhere.upgrade-insecure-requests— useful when migrating legacy content with hard-codedhttp://assets. MDN is clear it is not a replacement for HSTS: it upgrades resource loads, not the user's first navigation.
Source expressions you'll see: 'self' (same origin), 'none', a host like https://cdn.example.com, a scheme like https: or data:, plus the keywords 'nonce-…', 'sha256-…', 'strict-dynamic', 'unsafe-inline', 'unsafe-eval', and 'wasm-unsafe-eval'. The single quotes around keywords are mandatory — self without quotes is parsed as a hostname.
Why 'unsafe-inline' defeats the purpose
The overwhelming majority of real XSS is injected inline script: a script tag, an onerror= attribute, or a javascript: URL. 'unsafe-inline' in script-src tells the browser to run all of it. 'unsafe-eval' has the same problem for string-to-code sinks such as eval() and new Function().
Host allowlists are weaker than they look, too. The web.dev strict-CSP guidance cites research showing allowlist policies like script-src www.googleapis.com can be bypassed in most configurations, because big shared domains host JSONP endpoints and old library versions an attacker can load. That is why current guidance from MDN, web.dev, and OWASP converges on a strict CSP based on nonces or hashes instead of domain lists.
Nonces vs hashes
A nonce is a random value generated fresh for every response. You put it in the header and on each script tag you trust:
Content-Security-Policy: script-src 'nonce-rAnd0m123' 'strict-dynamic'; object-src 'none'; base-uri 'none'<script nonce="rAnd0m123" src="/app.js"></script>An injected script has no way to know the nonce, so it doesn't run. The rules are strict: the nonce must be cryptographically random (web.dev recommends 128 bits or more, base64-encoded), different on every response, and never reused. That last requirement is the catch — a nonce means the HTML must be rendered per request, which conflicts with full-page caching at a CDN or Dispatcher.
A hash is the base64 SHA-256/384/512 digest of an inline script's exact contents:
Content-Security-Policy: script-src 'sha256-{BASE64_HASH_OF_INLINE_SCRIPT}' 'strict-dynamic'; object-src 'none'; base-uri 'none'Hashes are static, so they survive caching — ideal for static sites and cached AEM pages — but any change to the script changes the hash, and external scripts need a matching integrity attribute.
One compatibility detail from MDN and the spec: when a directive contains a nonce or hash, browsers ignore 'unsafe-inline' in that directive. That's what makes the web.dev backward-compatible policy safe:
Content-Security-Policy: script-src 'nonce-{RANDOM}' 'strict-dynamic' https: 'unsafe-inline'; object-src 'none'; base-uri 'none''strict-dynamic'
Nonces can't be added to scripts created at runtime by a tag manager or bundle loader. 'strict-dynamic' solves this: trust flows from a nonced or hashed script to any script it creates programmatically. When 'strict-dynamic' is present, the browser ignores host allowlists, scheme sources, 'self', and 'unsafe-inline' in script-src — trust comes only from nonces and hashes.
The honest caveat, also from MDN: you're now trusting everything your trusted scripts choose to load. A compromised tag manager container becomes a script-loading gadget.
Report-only mode and the Reporting API
Never ship a new CSP straight to enforcement. Send it as Content-Security-Policy-Report-Only first: the browser evaluates the policy, reports every violation, and blocks nothing. You can run both headers at once — enforce the old policy while testing a stricter one in report-only.
Reports go to an endpoint you declare. The current mechanism is the Reporting-Endpoints header (which replaces the older Report-To header) together with the CSP report-to directive:
Reporting-Endpoints: csp-endpoint="https://example.com/csp-reports"
Content-Security-Policy-Report-Only: script-src 'nonce-rAnd0m123' 'strict-dynamic'; object-src 'none'; base-uri 'none'; report-to csp-endpointThe browser POSTs JSON with content type application/reports+json and "type": "csp-violation", including effectiveDirective, blockedURL, documentURL, and a sample if you add 'report-sample' to the directive. Endpoints must be HTTPS — MDN notes non-secure endpoints are ignored.
The older report-uri directive is deprecated, but support for report-to is still not universal, so MDN recommends sending both for now; browsers that support report-to ignore report-uri:
Content-Security-Policy: ...; report-uri https://example.com/csp-reports; report-to csp-endpointNote:
frame-ancestors,report-uri,report-to, andsandboxare not supported when CSP is delivered in an HTMLmetatag, and a meta tag cannot carry a report-only policy. Deliver CSP as a real response header.
Rolling out CSP safely
- Inventory what the site loads: first-party bundles, tag managers, analytics, fonts, embeds, and any inline
scriptblocks. - Refactor inline code. Move inline event handlers (
onclick=) andjavascript:URLs toaddEventListener— strict CSP blocks them, and web.dev calls them out as the main refactoring cost. - Deploy report-only with your target strict policy and collect reports for at least a full release cycle and real traffic.
- Enforce, keeping the report-only header in place for the next tightening step.
- Verify with Google's CSP Evaluator or Lighthouse, which web.dev recommends for confirming a policy is actually strict.
Tip: Multiple CSP headers are all enforced — a resource must satisfy every policy. That means a second policy can only make things stricter, never looser.
Strict-Transport-Security (HSTS)
HSTS tells the browser: for the next max-age seconds, only ever talk to this host over HTTPS. It auto-upgrades http:// requests before they hit the network, and — critically — it prevents users from clicking through certificate errors.
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload| Directive | Meaning |
|---|---|
max-age=N | Seconds the browser remembers the policy. max-age=0 removes it. |
includeSubDomains | Applies the policy to every subdomain of the host. |
preload | Non-standard (not in RFC 6797) signal that you consent to inclusion in browser preload lists. |
Two behaviors matter operationally. First, browsers ignore HSTS sent over plain HTTP, so the header only takes effect after a successful HTTPS response. Second, HSTS is trust-on-first-use: the very first visit is still exposed. Preloading fixes that by shipping your domain inside the browser itself.
Preload list requirements
To submit at hstspreload.org, the site must:
- Serve a valid certificate.
- Redirect HTTP to HTTPS on the same host if it listens on port 80.
- Serve all subdomains over HTTPS, including
wwwif it has a DNS record. - Send an HSTS header on the base domain with
max-ageof at least31536000(one year),includeSubDomains, andpreload. - Include the HSTS header on any additional redirect served from the HTTPS site.
Important: Preloading is effectively one-way. hstspreload.org warns that removal takes months to reach users through browser updates. If any subdomain — an old intranet host, a vendor-managed marketing site — can't do HTTPS,
includeSubDomainsplus preload will break it.
Roll out gradually, as hstspreload.org recommends: start with a max-age of 5 minutes, then a week, then a month, checking for breakage at each step, before moving to one or two years and preloading. OWASP's recommended final value is max-age=63072000; includeSubDomains; preload (two years), with a warning that a long-lived policy plus an expired certificate locks users out until the policy expires.
X-Content-Type-Options: nosniff
X-Content-Type-Options: nosniffIt has exactly one valid value. Per MDN, nosniff does two things: it blocks a response requested as a script unless it has a JavaScript MIME type, or as a stylesheet unless it's text/css; and for everything else it stops the browser from second-guessing the declared Content-Type. That shuts down MIME-confusion attacks where an uploaded "text file" gets rendered as HTML or executed as script.
Frame protection: X-Frame-Options vs frame-ancestors
Clickjacking loads your page in an invisible iframe and tricks the user into clicking through it. Two headers prevent that:
X-Frame-Options | CSP frame-ancestors | |
|---|---|---|
| Values | DENY, SAMEORIGIN | 'none', 'self', hosts, schemes |
| Allow specific partner origins | ❌ (ALLOW-FROM is obsolete; browsers ignore the header) | ✅ |
Works in a meta tag | ❌ | ❌ |
| Status | Legacy, still widely honored | Current standard |
The specs say frame-ancestors obsoletes X-Frame-Options, and when a page has an enforced frame-ancestors directive, browsers ignore X-Frame-Options. The pragmatic recommendation (OWASP's too) is to use frame-ancestors as the real control and keep a matching X-Frame-Options for older clients and scanners:
Content-Security-Policy: frame-ancestors 'none'
X-Frame-Options: DENYUse 'self' and SAMEORIGIN instead if you frame your own pages.
Referrer-Policy
Referrer-Policy controls how much of the current URL is sent in the Referer header on outgoing requests and navigations.
| Value | Sends |
|---|---|
no-referrer | Nothing |
no-referrer-when-downgrade | Full URL unless going HTTPS to HTTP (the old browser default) |
origin | Only the origin, always |
origin-when-cross-origin | Full URL same-origin; origin only cross-origin |
same-origin | Full URL same-origin; nothing cross-origin |
strict-origin | Origin only, and nothing on HTTPS to HTTP |
strict-origin-when-cross-origin | Full URL same-origin; origin cross-origin; nothing on downgrade |
unsafe-url | Full URL everywhere — leaks paths and query strings |
strict-origin-when-cross-origin has been the browser default since late 2020, and it's OWASP's recommendation. Set it explicitly anyway so older browsers and embedded webviews behave the same. Go stricter (same-origin or no-referrer) on pages whose URLs contain tokens, IDs, or search terms you don't want in third-party analytics. Never use unsafe-url.
Permissions-Policy
Permissions-Policy controls which powerful browser features a page — and the iframes inside it — may use.
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(self "https://pay.example.com")The syntax is a structured-header dictionary, and it is not CSP syntax:
| Allowlist | Meaning |
|---|---|
() | Disabled everywhere, including the page itself |
(self) | Allowed for this origin and same-origin frames |
* | Allowed everywhere, including cross-origin frames |
(self "https://a.example.com") | This origin plus the listed origins — note the double quotes |
Note that self is bare here, not 'self' as in CSP, and origins are double-quoted. Mixing the two syntaxes is the most common reason a Permissions-Policy silently does nothing. MDN still marks the header as experimental with limited availability — it's enforced in Chromium-based browsers but not everywhere — so treat it as hardening, not a primary control.
Two legacy items to clean up:
Feature-Policyis the predecessor header, with a different syntax. Replace it withPermissions-Policy.interest-cohort=()was the opt-out for Google's FLoC experiment, which Chrome abandoned. It is no longer a listed feature on MDN, and Chromium logs an unrecognized-feature warning in the console. Remove it from existing configs.
Cross-origin isolation: COOP, COEP, and CORP
These three headers defend against cross-window attacks and Spectre-class side channels, and together they unlock cross-origin isolation.
Cross-Origin-Opener-Policy (COOP) controls whether cross-origin windows you open, or that open you, share your browsing context group (and therefore get a window.opener handle to you).
| Value | Effect |
|---|---|
unsafe-none | Default; no isolation |
same-origin-allow-popups | Isolates you, but popups you open keep a reference — for OAuth and payment flows |
same-origin | Only same-origin documents with the same policy share your context group |
noopener-allow-popups | Always opens in a new group; severs opener links even for same-origin pages |
Cross-Origin-Embedder-Policy (COEP) controls whether the page may load cross-origin no-cors resources that haven't explicitly opted in. require-corp blocks them unless they send CORP or are loaded with CORS; credentialless allows them but strips cookies (MDN flags its support as limited). There's also a Cross-Origin-Embedder-Policy-Report-Only variant for testing.
Cross-Origin-Resource-Policy (CORP) is set on resources (images, scripts, API responses) and says who may embed them: same-origin, same-site, or cross-origin.
Setting Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp makes the page cross-origin isolated, which enables SharedArrayBuffer and unthrottled high-resolution timers.
Tip: COOP
same-originis cheap and safe for most sites — unless you rely on popups that talk back to you (social login, payment windows), in which case usesame-origin-allow-popups. COEPrequire-corp, by contrast, breaks every third-party image, font, or embed that doesn't send CORP or CORS headers. Only turn on COEP if you actually need cross-origin isolation, and test it in report-only first.
Cookie security attributes
Cookies aren't a security header, but Set-Cookie attributes are security-critical — especially session cookies.
| Attribute | What it does |
|---|---|
Secure | Only sent over HTTPS |
HttpOnly | Not readable by JavaScript (document.cookie), so XSS can't steal it — it's still sent on fetch calls |
SameSite=Strict | Never sent on cross-site requests |
SameSite=Lax | Sent cross-site only on top-level navigations with safe methods (GET) |
SameSite=None | Sent everywhere; requires Secure |
Partitioned | Stores the cookie per top-level site (CHIPS); requires Secure |
Some browsers default to Lax when SameSite is missing, but not all — set it explicitly.
Name prefixes make the browser enforce the attributes for you:
__Secure-— must be set withSecurefrom an HTTPS page.__Host-— must beSecure, have noDomainattribute, and havePath=/. The cookie is locked to the exact host and can't be overwritten by a subdomain.__Http-and__Host-Http-— newer prefixes on MDN that additionally requireHttpOnly, proving the cookie was set by the server and not by script. Check browser support before relying on them.
A solid session cookie:
Set-Cookie: __Host-session=abc123; Path=/; Secure; HttpOnly; SameSite=LaxDeprecated headers to remove
| Header | Status | What to do |
|---|---|---|
X-XSS-Protection | Non-standard; the browser XSS filters it controlled have been retired, and MDN warns it can create XSS holes | Omit it, or send X-XSS-Protection: 0 (OWASP's recommendation). Rely on CSP. |
Expect-CT | Only Chromium implemented it; deprecated from Chrome 107 because CT is now enforced by default | Remove it |
Public-Key-Pins (HPKP) | Removed from Chromium in 2018; unsupported by modern browsers | Remove it; rely on Certificate Transparency and CAA DNS records |
Feature-Policy | Replaced by Permissions-Policy | Migrate |
CSP block-all-mixed-content, report-uri | Deprecated directives | Use upgrade-insecure-requests and report-to |
Cache-Control for sensitive responses
A response that contains personal data, account pages, or tokens must not end up in a shared cache — or on a shared computer's disk.
Cache-Control: no-storeOWASP's guidance is precise: no-store prevents storage entirely; private keeps a response out of shared caches (CDN, Dispatcher) but still allows the browser to store it; and no-cache does not prevent caching at all — it only forces revalidation. On AEM this is doubly important: a personalized response cached by the Dispatcher or CDN is served to the next visitor.
Information-leak headers: Server and X-Powered-By
Headers like Server: Apache/2.4.x (Unix) and X-Powered-By: Next.js tell an attacker which CVE list to check. OWASP recommends removing X-Powered-By and removing or blanking Server — hygiene, not a defense. In Next.js, set poweredByHeader: false; on self-managed Apache, use ServerTokens Prod and ServerSignature Off; in Nginx, server_tokens off.
Implementation
The golden rule: set each header in exactly one layer. If the CDN, the web server, and the application all add headers independently, you get duplicates, conflicting values, or stacked CSPs.
Next.js
For headers that don't vary per request, use headers() in next.config. This works for static and dynamic routes alike:
// next.config.ts
import type { NextConfig } from "next";
const securityHeaders = [
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
];
const nextConfig: NextConfig = {
poweredByHeader: false,
async headers() {
return [{ source: "/(.*)", headers: securityHeaders }];
},
};
export default nextConfig;For a strict, nonce-based CSP, current Next.js docs (v16) generate the nonce in Proxy — the proxy.ts file convention that replaced middleware.ts. The proxy sets the CSP on both the request (so Next.js can read it during rendering) and the response:
// proxy.ts
import { NextRequest, NextResponse } from "next/server";
export function proxy(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
const isDev = process.env.NODE_ENV === "development";
const csp = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${isDev ? " 'unsafe-eval'" : ""};
style-src 'self' 'nonce-${nonce}';
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`.replace(/\s{2,}/g, " ").trim();
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-nonce", nonce);
requestHeaders.set("Content-Security-Policy", csp);
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set("Content-Security-Policy", csp);
return response;
}
export const config = {
matcher: [
{
source: "/((?!api|_next/static|_next/image|favicon.ico).*)",
missing: [
{ type: "header", key: "next-router-prefetch" },
{ type: "header", key: "purpose", value: "prefetch" },
],
},
],
};Next.js parses the nonce out of the CSP header during server rendering and applies it automatically to framework scripts, page bundles, and its own inline scripts. For your own third-party scripts, read it in a Server Component with (await headers()).get("x-nonce") and pass it to next/script.
Important: Nonces require dynamic rendering. The Next.js docs are explicit: with a nonce-based CSP, static optimization and ISR are disabled for those pages, pages can't be cached at the CDN by default, and Partial Prerendering is incompatible.
'unsafe-eval'is needed only in development (React usesevalfor debugging) — never in production. If you need static pages, Next.js offers experimental hash-based Subresource Integrity (experimental.sri) as an alternative.
Apache and the AEM Dispatcher
Apache sets headers with mod_headers, which is on the AEM as a Cloud Service Dispatcher allowlist. Two details matter:
- Use
Header always set. PlainHeader setuses theonsuccesstable, so the header is missing from error pages and redirects — exactly where HSTS is required for preloading. onsuccessandalwaysare separate tables. A header that AEM or a proxied backend already sent can end up duplicated. OWASP's pattern is to unset first, thenalways set.
# conf.d/available_vhosts/mysite.vhost — inside <VirtualHost *:80>
<IfModule mod_headers.c>
Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
Header always set X-Content-Type-Options "nosniff"
Header unset X-Frame-Options
Header always set X-Frame-Options "SAMEORIGIN"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"
Header always set Cross-Origin-Opener-Policy "same-origin"
Header always set Content-Security-Policy-Report-Only "default-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; report-to csp-endpoint"
Header always set Reporting-Endpoints "csp-endpoint=\"https://www.example.com/csp-reports\""
Header unset X-Powered-By
Header always unset X-Powered-By
</IfModule>On AEM, watch for duplicate headers. Adobe's knowledge base documents that AEM as a Cloud Service already adds X-Frame-Options: SAMEORIGIN through the Sling Main Servlet's sling.additional.response.headers OSGi setting, so adding it again in the vhost produces two copies. Adobe's resolution: set each security header in one place only — AEM, or the Dispatcher/CDN — not both. The unset-then-set pattern above avoids the duplicate.
Because Dispatcher-cached pages are the same bytes for every visitor, per-request nonces don't fit a cached AEM site. Prefer hashes for the few inline scripts you control, move the rest to clientlibs, and use 'strict-dynamic' for loader scripts. On self-managed Apache (AMS, on-prem), also add ServerTokens Prod and ServerSignature Off in the server config.
AEM as a Cloud Service CDN
AEM as a Cloud Service lets you set response headers at the Adobe-managed CDN with response transformation rules in a cdn.yaml, deployed through the Cloud Manager config pipeline. The file lives under the /config folder at the root of your repository:
# config/cdn.yaml
kind: "CDN"
version: "1"
metadata:
envTypes: ["dev", "stage", "prod"]
data:
responseTransformations:
rules:
- name: security-headers
when:
reqProperty: tier
equals: publish
actions:
- type: set
respHeader: Strict-Transport-Security
value: "max-age=63072000; includeSubDomains"
- type: set
respHeader: X-Content-Type-Options
value: "nosniff"
- type: set
respHeader: Referrer-Policy
value: "strict-origin-when-cross-origin"
- type: set
respHeader: Permissions-Policy
value: "camera=(), microphone=(), geolocation=()"
- type: set
respHeader: Content-Security-Policy
value: "object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'"The mechanics come straight from Adobe's CDN configuration docs: when accepts a single condition or allOf/anyOf groups using getters such as reqProperty (path, domain, tier, and more), with tier returning author, preview, or publish; actions supports set with respHeader and value, and unset with respHeader to remove a header. metadata.envTypes is optional — omit it and the file applies to all environment types.
The CDN talks to the browser over HTTPS and is a natural single place for site-wide policy. Choose it or the Dispatcher for each header — not both — and remember AEM's own default X-Frame-Options. For more on the pipelines, see the AEM as a Cloud Service guide; for Edge Delivery sites, see the Edge Delivery Services guide.
Nginx
server {
listen 443 ssl;
server_name www.example.com;
server_tokens off;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
}Two Nginx traps:
- Without
always,add_headeronly applies to a fixed set of success and redirect status codes — not 4xx/5xx. add_headerdirectives are inherited from the enclosing level only if the current level defines none. Add a singleadd_headerinside alocationblock and every server-level security header silently disappears for that location. Nginx 1.29.3 addedadd_header_inherit mergeto change this; on older versions, repeat the headers or use anincludesnippet.
Testing your headers
Verify the headers the browser actually receives — from the public hostname, through the CDN.
- Browser DevTools — the Network panel shows response headers for each request, and the Console reports CSP violations and Permissions-Policy parse errors.
- curl —
curl -Isends a HEAD request, which some stacks answer differently, so a GET that discards the body is more reliable:
curl -sI https://www.example.com/
curl -s -D - -o /dev/null https://www.example.com/ # GET, headers only
curl -s -D - -o /dev/null https://www.example.com/does-not-exist # error pages too
curl -s -D - -o /dev/null http://www.example.com/ # the redirect- Security Headers Auditor — grades a live URL's headers, explains each finding, and generates a copy-paste Next.js config.
- Mozilla HTTP Observatory — now hosted on MDN at
developer.mozilla.org/en-US/observatory; it assesses headers and other security configuration and scores the result. - securityheaders.com — the free web scanner is still available; its API was discontinued in 2026, so don't build CI on it.
Tip: Test a 404, a redirect, and a cached page, not just the homepage. Missing headers on error pages and redirects are the most common finding in audits — and the reason
alwaysexists.
Cheat sheet
| Header | Recommended baseline | Notes |
|---|---|---|
Content-Security-Policy | script-src 'nonce-…' 'strict-dynamic'; object-src 'none'; base-uri 'none' plus frame-ancestors, form-action | Start in report-only; use hashes for cached HTML |
Strict-Transport-Security | max-age=63072000; includeSubDomains; preload | Ramp up max-age; preload is hard to undo |
X-Content-Type-Options | nosniff | Only valid value |
X-Frame-Options | DENY or SAMEORIGIN | Legacy backup for frame-ancestors |
Referrer-Policy | strict-origin-when-cross-origin | Stricter for token-bearing URLs |
Permissions-Policy | camera=(), microphone=(), geolocation=() | Bare self, double-quoted origins |
Cross-Origin-Opener-Policy | same-origin | same-origin-allow-popups for OAuth/payments |
Cross-Origin-Embedder-Policy | Only if you need isolation: require-corp | Breaks non-CORP third-party resources |
Cross-Origin-Resource-Policy | same-site (OWASP) | Set on resources |
Reporting-Endpoints | csp-endpoint="https://…" | HTTPS endpoints only |
Set-Cookie | __Host- prefix, Secure; HttpOnly; SameSite=Lax; Path=/ | Session cookies |
Cache-Control | no-store on sensitive responses | no-cache does not prevent storage |
X-XSS-Protection | Omit, or 0 | Never 1; mode=block |
Expect-CT, Public-Key-Pins, Feature-Policy | Remove | Obsolete |
Server, X-Powered-By | Remove or blank | Hygiene only |
Best practices
- ✅ Set every security header in one layer — CDN, web server, or app — and document which.
- ✅ Use strict CSP (nonces or hashes plus
'strict-dynamic') rather than long host allowlists. - ✅ Always set
object-src,base-uri,form-action, andframe-ancestorsexplicitly — they don't all fall back todefault-src. - ✅ Roll out CSP in report-only first and keep a reporting endpoint running after enforcement.
- ✅ Ramp HSTS
max-agegradually and audit every subdomain beforeincludeSubDomainsand preload. - ✅ Send headers on all responses, including errors and redirects (
alwaysin Apache and Nginx). - ✅ Use
__Host-cookies withSecure,HttpOnly, and an explicitSameSite. - ✅ Mark sensitive and personalized responses
Cache-Control: no-store.
Do's and Don'ts
Do
- ✅ Deliver CSP as an HTTP header, not a
metatag. - ✅ Generate a fresh, cryptographically random nonce for every response.
- ✅ Keep
X-Frame-Optionsconsistent with yourframe-ancestorsvalue. - ✅ Check for duplicate headers on AEM, where the Sling Main Servlet already adds
X-Frame-Options.
Don't
- ❌ Don't put
'unsafe-inline'or'unsafe-eval'inscript-srcand call it a CSP. - ❌ Don't reuse a nonce across responses — or cache HTML that contains one.
- ❌ Don't preload HSTS before every subdomain serves HTTPS.
- ❌ Don't send
X-XSS-Protection: 1; mode=block,Expect-CT, orPublic-Key-Pins. - ❌ Don't write Permissions-Policy with CSP syntax (
'self') or keepinterest-cohort=()around. - ❌ Don't enable COEP
require-corpunless you need cross-origin isolation and have tested every embed. - ❌ Don't rely on
no-cacheto keep sensitive data out of caches.
Wrapping up
The quick wins — nosniff, Referrer-Policy, frame protection, Permissions-Policy, and cleaning out deprecated headers — take an afternoon. HSTS takes a staged rollout and a subdomain audit. CSP takes real work: refactoring inline scripts, choosing nonces or hashes to fit your caching model, and running report-only until the violations dry up. But a strict CSP is the header most likely to stop an XSS bug from becoming an incident, so it's worth doing properly.
Continue with the AEM Dispatcher guide for vhost and filter hardening, the AEM Security guide for users, groups, and ACLs, and Next.js App Router for AEM developers for the Next.js side. Then run your site through the Security Headers Auditor and the Website Audit to see where you stand.
Discussion
Loading discussion…
Try a related tool
Subscribe to the Newsletter
Get the latest articles, tutorials, and tech insights delivered straight to your inbox. No spam, unsubscribe anytime.

