Adobe AEM

Web Security Headers: The Complete Guide (CSP, HSTS & More)

26 min read

A practical, standards-checked guide to HTTP security headers — Content-Security-Policy in depth (nonces, hashes, strict-dynamic, reporting, safe rollout), HSTS and preloading, nosniff, frame protection, Referrer-Policy, Permissions-Policy, COOP/COEP/CORP, cookie attributes, deprecated headers, and how to set them in Next.js, Apache/AEM Dispatcher, the AEM as a Cloud Service CDN, and Nginx. Includes code, a cheat sheet, best practices, and do's & don'ts.

SecurityWebCSPHTTPNext.jsAEM
Web Security Headers: The Complete Guide (CSP, HSTS & More)

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

ThreatPrimary header(s)
Cross-site scripting (XSS) and content injectionContent-Security-Policy
Protocol downgrade, SSL stripping, cookie hijacking on HTTPStrict-Transport-Security
ClickjackingCSP frame-ancestors, X-Frame-Options
MIME-confusion attacks (text served as script or HTML)X-Content-Type-Options: nosniff
URL leakage to third partiesReferrer-Policy
Abuse of powerful browser features (camera, geolocation)Permissions-Policy
Cross-window attacks and side-channel (Spectre-class) leaksCross-Origin-Opener-Policy, Cross-Origin-Embedder-Policy, Cross-Origin-Resource-Policy
Session theft and cross-site request forgeryCookie attributes: Secure, HttpOnly, SameSite, prefixes
Sensitive data persisting in cachesCache-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 curl script, 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-Options are 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

DirectiveControlsFalls back to default-src?
default-srcFallback for every fetch directive not set explicitly—
script-srcJavaScript: external files, inline scripts, event handlers, evalYes
style-srcStylesheets, inline style elements and attributesYes
img-srcImages and faviconsYes
connect-srcfetch, XHR, WebSocket, EventSource, sendBeaconYes
font-src, media-src, frame-src, worker-src, manifest-srcTheir respective resource typesYes
object-srcobject and embed pluginsYes
frame-ancestorsWhich sites may embed this pageNo
base-uriAllowed values for the document's base elementNo
form-actionWhere forms may submitNo
upgrade-insecure-requestsRewrites 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, and object/embed were a classic script-injection vector.
  • base-uri 'none' (or 'self') — without it, an injected base tag 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-coded http:// 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-endpoint

The 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-endpoint

Note: frame-ancestors, report-uri, report-to, and sandbox are not supported when CSP is delivered in an HTML meta tag, and a meta tag cannot carry a report-only policy. Deliver CSP as a real response header.

Rolling out CSP safely

  1. Inventory what the site loads: first-party bundles, tag managers, analytics, fonts, embeds, and any inline script blocks.
  2. Refactor inline code. Move inline event handlers (onclick=) and javascript: URLs to addEventListener — strict CSP blocks them, and web.dev calls them out as the main refactoring cost.
  3. Deploy report-only with your target strict policy and collect reports for at least a full release cycle and real traffic.
  4. Enforce, keeping the report-only header in place for the next tightening step.
  5. 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
DirectiveMeaning
max-age=NSeconds the browser remembers the policy. max-age=0 removes it.
includeSubDomainsApplies the policy to every subdomain of the host.
preloadNon-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:

  1. Serve a valid certificate.
  2. Redirect HTTP to HTTPS on the same host if it listens on port 80.
  3. Serve all subdomains over HTTPS, including www if it has a DNS record.
  4. Send an HSTS header on the base domain with max-age of at least 31536000 (one year), includeSubDomains, and preload.
  5. 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, includeSubDomains plus 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: nosniff

It 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-OptionsCSP frame-ancestors
ValuesDENY, SAMEORIGIN'none', 'self', hosts, schemes
Allow specific partner origins❌ (ALLOW-FROM is obsolete; browsers ignore the header)✅
Works in a meta tag❌❌
StatusLegacy, still widely honoredCurrent 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: DENY

Use '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.

ValueSends
no-referrerNothing
no-referrer-when-downgradeFull URL unless going HTTPS to HTTP (the old browser default)
originOnly the origin, always
origin-when-cross-originFull URL same-origin; origin only cross-origin
same-originFull URL same-origin; nothing cross-origin
strict-originOrigin only, and nothing on HTTPS to HTTP
strict-origin-when-cross-originFull URL same-origin; origin cross-origin; nothing on downgrade
unsafe-urlFull 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:

AllowlistMeaning
()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-Policy is the predecessor header, with a different syntax. Replace it with Permissions-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).

ValueEffect
unsafe-noneDefault; no isolation
same-origin-allow-popupsIsolates you, but popups you open keep a reference — for OAuth and payment flows
same-originOnly same-origin documents with the same policy share your context group
noopener-allow-popupsAlways 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-origin is cheap and safe for most sites — unless you rely on popups that talk back to you (social login, payment windows), in which case use same-origin-allow-popups. COEP require-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.

Cookies aren't a security header, but Set-Cookie attributes are security-critical — especially session cookies.

AttributeWhat it does
SecureOnly sent over HTTPS
HttpOnlyNot readable by JavaScript (document.cookie), so XSS can't steal it — it's still sent on fetch calls
SameSite=StrictNever sent on cross-site requests
SameSite=LaxSent cross-site only on top-level navigations with safe methods (GET)
SameSite=NoneSent everywhere; requires Secure
PartitionedStores 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 with Secure from an HTTPS page.
  • __Host- — must be Secure, have no Domain attribute, and have Path=/. 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 require HttpOnly, 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=Lax

Deprecated headers to remove

HeaderStatusWhat to do
X-XSS-ProtectionNon-standard; the browser XSS filters it controlled have been retired, and MDN warns it can create XSS holesOmit it, or send X-XSS-Protection: 0 (OWASP's recommendation). Rely on CSP.
Expect-CTOnly Chromium implemented it; deprecated from Chrome 107 because CT is now enforced by defaultRemove it
Public-Key-Pins (HPKP)Removed from Chromium in 2018; unsupported by modern browsersRemove it; rely on Certificate Transparency and CAA DNS records
Feature-PolicyReplaced by Permissions-PolicyMigrate
CSP block-all-mixed-content, report-uriDeprecated directivesUse 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-store

OWASP'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 uses eval for 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. Plain Header set uses the onsuccess table, so the header is missing from error pages and redirects — exactly where HSTS is required for preloading.
  • onsuccess and always are separate tables. A header that AEM or a proxied backend already sent can end up duplicated. OWASP's pattern is to unset first, then always 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_header only applies to a fixed set of success and redirect status codes — not 4xx/5xx.
  • add_header directives are inherited from the enclosing level only if the current level defines none. Add a single add_header inside a location block and every server-level security header silently disappears for that location. Nginx 1.29.3 added add_header_inherit merge to change this; on older versions, repeat the headers or use an include snippet.

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 -I sends 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 always exists.

Cheat sheet

HeaderRecommended baselineNotes
Content-Security-Policyscript-src 'nonce-…' 'strict-dynamic'; object-src 'none'; base-uri 'none' plus frame-ancestors, form-actionStart in report-only; use hashes for cached HTML
Strict-Transport-Securitymax-age=63072000; includeSubDomains; preloadRamp up max-age; preload is hard to undo
X-Content-Type-OptionsnosniffOnly valid value
X-Frame-OptionsDENY or SAMEORIGINLegacy backup for frame-ancestors
Referrer-Policystrict-origin-when-cross-originStricter for token-bearing URLs
Permissions-Policycamera=(), microphone=(), geolocation=()Bare self, double-quoted origins
Cross-Origin-Opener-Policysame-originsame-origin-allow-popups for OAuth/payments
Cross-Origin-Embedder-PolicyOnly if you need isolation: require-corpBreaks non-CORP third-party resources
Cross-Origin-Resource-Policysame-site (OWASP)Set on resources
Reporting-Endpointscsp-endpoint="https://…"HTTPS endpoints only
Set-Cookie__Host- prefix, Secure; HttpOnly; SameSite=Lax; Path=/Session cookies
Cache-Controlno-store on sensitive responsesno-cache does not prevent storage
X-XSS-ProtectionOmit, or 0Never 1; mode=block
Expect-CT, Public-Key-Pins, Feature-PolicyRemoveObsolete
Server, X-Powered-ByRemove or blankHygiene 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, and frame-ancestors explicitly — they don't all fall back to default-src.
  • ✅ Roll out CSP in report-only first and keep a reporting endpoint running after enforcement.
  • ✅ Ramp HSTS max-age gradually and audit every subdomain before includeSubDomains and preload.
  • ✅ Send headers on all responses, including errors and redirects (always in Apache and Nginx).
  • ✅ Use __Host- cookies with Secure, HttpOnly, and an explicit SameSite.
  • ✅ Mark sensitive and personalized responses Cache-Control: no-store.

Do's and Don'ts

Do

  • ✅ Deliver CSP as an HTTP header, not a meta tag.
  • ✅ Generate a fresh, cryptographically random nonce for every response.
  • ✅ Keep X-Frame-Options consistent with your frame-ancestors value.
  • ✅ 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' in script-src and 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, or Public-Key-Pins.
  • ❌ Don't write Permissions-Policy with CSP syntax ('self') or keep interest-cohort=() around.
  • ❌ Don't enable COEP require-corp unless you need cross-origin isolation and have tested every embed.
  • ❌ Don't rely on no-cache to 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.

Share this article

Discussion

By commenting you agree to the Privacy Policy. Guest comments are reviewed before they appear.

Loading discussion…

Subscribe to the Newsletter

Get the latest articles, tutorials, and tech insights delivered straight to your inbox. No spam, unsubscribe anytime.

Back to Blog