> For AI agents: the complete documentation index is available at https://nhanbe.id.vn/llms.txt, the full documentation bundle is available at https://nhanbe.id.vn/llms-full.txt.

# Content Security Policy and Security Headers Guide

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](https://www.kumaraman.in/blog/aem-dispatcher-the-complete-guide) (where most of these headers get set) and the [AEM as a Cloud Service guide](https://www.kumaraman.in/blog/aem-cloud-service-complete-guide). If you're on Next.js, see [Next.js App Router for AEM developers](https://www.kumaraman.in/blog/nextjs-app-router-for-aem-developers). And to check any live URL as you read, use the free [Security Headers Auditor](https://www.kumaraman.in/tools/security-headers).

## 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 `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

| 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, 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
```

| 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:

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-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: 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.

| 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-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).

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

## 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 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

| 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-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](https://www.kumaraman.in/blog/aem-cloud-service-complete-guide); for Edge Delivery sites, see the [Edge Delivery Services guide](https://www.kumaraman.in/blog/aem-edge-delivery-services-complete-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](https://www.kumaraman.in/tools/security-headers)** — 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

| 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`, 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](https://www.kumaraman.in/blog/aem-dispatcher-the-complete-guide) for vhost and filter hardening, the [AEM Security guide](https://www.kumaraman.in/blog/aem-security-users-groups-acls-complete-guide) for users, groups, and ACLs, and [Next.js App Router for AEM developers](https://www.kumaraman.in/blog/nextjs-app-router-for-aem-developers) for the Next.js side. Then run your site through the [Security Headers Auditor](https://www.kumaraman.in/tools/security-headers) and the [Website Audit](https://www.kumaraman.in/tools/website-audit) to see where you stand.
