---
key: "cors-csp-browser-boundaries"
title: "Configure CORS and CSP around the browser capabilities you need"
url: "https://softwaresecurity.com/domains/appsec/browser-security/cors-csp-browser-boundaries/"
api: "https://softwaresecurity.com/api/v1/articles/cors-csp-browser-boundaries.json"
domain: "appsec"
topic: "Application security"
type: "Guide"
identifiers: ["CWE-942","CWE-346","CWE-1021","A02:2025","API8:2023","V3.4.2","V3.4.3","V3.4.6","V3.4.7","V3.5.2","WSTG-CLNT-07"]
tags: ["Cross-site scripting (XSS)","Browser security"]
stages: ["build"]
roles: ["developer","appsec-engineer"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Configure CORS and CSP around the browser capabilities you need

Control who can read API responses and which resources a page may execute without confusing either control with authorization.

Canonical URL: https://softwaresecurity.com/domains/appsec/browser-security/cors-csp-browser-boundaries/

## In short

CORS and CSP solve different problems. CORS decides which other origins may read your API's responses: echo only exact allowlisted origins, add `Vary: Origin`, and never allow `*` with credentials or the `null` origin; it never blocks the request itself. CSP limits what a page may run: use a fresh nonce with `'strict-dynamic'`, `object-src 'none'`, `base-uri 'none'`, `frame-ancestors` and `require-trusted-types-for 'script'`, deployed in report-only mode first.

- CORS controls who can read a response; server-side authentication, CSRF checks and authorization still decide what happens.
- Simple requests, meaning `GET`, `HEAD` or `POST` with safelisted headers and a form or `text/plain` body, are sent without a preflight.
- Allow exact origins only: no reflection, no suffix or pattern matching and never `null`.
- A strict CSP relies on fresh nonces and `'strict-dynamic'`, not host allowlists, which have well-known bypasses.
- Trusted Types make DOM-injection sinks throw unless a reviewed policy produced the value.

## Recommended defaults

| Setting | Value | Note | Source |
| --- | --- | --- | --- |
| `Access-Control-Allow-Origin` | One exact origin from a server-side list, echoed per request; never `*` with credentials, never `null` | — | OWASP ASVS v5.0.0 V3.4.2; Fetch Standard |
| `Vary` | `Origin` on every response whose CORS headers depend on it | — | Fetch Standard |
| `script-src` | `'nonce-<fresh value>' 'strict-dynamic'` | Generate a new unpredictable nonce for every response. | W3C CSP Level 3; web.dev strict CSP |
| Other CSP directives | `object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'` | — | OWASP ASVS v5.0.0 V3.4.3 and V3.4.6 |
| Trusted Types | `require-trusted-types-for 'script'; trusted-types <policy names>` | Baseline across current browsers since February 2026. | MDN: Trusted Types API |
| Reporting | `report-to` with a `Reporting-Endpoints` header; roll out with `Content-Security-Policy-Report-Only` | — | OWASP ASVS v5.0.0 V3.4.7 |

## Verify it

1. Fetch the API from an unlisted origin and from a sandboxed iframe, which sends `Origin: null`; neither response may be readable.
2. Confirm a `POST` with `Content-Type: application/json` triggers a preflight, and that the API rejects the same body sent as `text/plain`.
3. Check that every origin-dependent API response carries `Vary: Origin`.
4. Run the policy through a CSP evaluator, then confirm an injected inline script and a disallowed connection are blocked.
5. Deploy the policy as `Content-Security-Policy-Report-Only` first and review the reports before enforcing.

## What you will learn

- Explain the distinct CORS and CSP boundaries.
- Configure exact credentialed origins and nonce-based execution.
- Diagnose browser errors without assuming mutations were blocked.

Audience: Web and API developers configuring cross-origin access and Content Security Policy, and reviewers of browser security headers.

Before you start:

- Distinguish origin, site, browser response access and server authorization.

Time: About 25 minutes: 7 to read and 18 for the exercise

## Distinguish two browser decisions

CORS and CSP operate at different boundaries. CORS determines whether a browser makes a cross-origin response available to requesting script, and for some requests whether a preflight permits the actual request. CSP constrains capabilities of a protected document, such as which scripts it may execute and where it may connect. Neither decides whether an authenticated user owns an invoice. A response can satisfy CORS while containing unauthorized data, and a perfect CSP cannot repair that server-side authorization error.

Imagine `portal.example.test` calling `api.example.test` for Alice's invoice. These are different origins because their hosts differ; scheme and port are part of the origin too. The API may allow the portal origin to read a credentialed response, while still requiring a valid session and invoice permission. An attacker-controlled page at `other.example.test` may be same-site yet cross-origin. Naming it 'internal' or trusting every subdomain does not make it part of the application's intended browser trust boundary.

## Write a narrow CORS contract and preserve cache correctness

For a credentialed browser request, the response needs the specific allowed origin and `Access-Control-Allow-Credentials: true`. A wildcard allow-origin cannot be used to grant a credentialed response to script. Compare the incoming `Origin` to an exact configured set; reflecting any submitted value with credentials enabled defeats that restriction. When the response varies by `Origin`, include `Vary: Origin` and configure shared caching consistently.

A browser sends a preflight `OPTIONS` request before a cross-origin request that is not simple. A request is simple only when its method is `GET`, `HEAD` or `POST`, it sets only CORS-safelisted headers such as `Accept`, `Accept-Language`, `Content-Language` and `Content-Type`, and any `Content-Type` is `application/x-www-form-urlencoded`, `multipart/form-data` or `text/plain`. So a `POST` with `Content-Type: application/json` is always preflighted, custom header or not, while the same JSON sent as `text/plain` is not: an API that parses JSON regardless of `Content-Type` accepts it with no preflight, which is a classic CSRF bypass. Require `application/json` on JSON endpoints. The preflight answers a protocol question; the actual request still needs authentication, authorization and CSRF checks, and non-browser clients ignore CORS entirely.

Two allowlist mistakes recur. Never allow the literal `null` origin: sandboxed iframes, `data:` URLs and some redirects send `Origin: null`, so any page can produce it. Never match origins by suffix or by an unanchored pattern; a test such as "ends with `example.test`" admits `https://evil-example.test`, and trusting every subdomain makes one vulnerable or abandoned subdomain a key to the API. The module below keeps a set of exact origins, adds `Vary: Origin` to every response and returns no grant otherwise. In a Chrome test against local servers, the listed portal origin could read a credentialed response, while an unlisted origin and a sandboxed iframe could not, a `text/plain` `POST` from the unlisted origin still reached the server, and a JSON `POST` from it stopped at the preflight. The CORS lab at /labs/cors-policy/ has you repair a policy that reflects any origin, and the fix cards at /fix/cors-misconfiguration/ show the exact-origin list in four frameworks. How abandoned subdomains are taken over, and how to prevent it, is in /domains/runtime/cloud-posture/subdomain-takeover/.

```javascript
// cors.mjs (Node.js 22+): exact-origin CORS for a cookie-authenticated API.
// List full origins as exact strings: no patterns, no suffix matching and never "null".
export function corsFor(allowedOrigins) {
  const allowed = new Set(allowedOrigins);
  return function corsHeaders(method, origin) {
    const headers = {Vary: 'Origin'}; // the answer depends on Origin, so caches must key on it
    if (!allowed.has(origin)) return headers; // no grant: the browser withholds the response
    headers['Access-Control-Allow-Origin'] = origin;
    headers['Access-Control-Allow-Credentials'] = 'true';
    if (method === 'OPTIONS') {
      // A preflight answers a protocol question; it is not the authorized request.
      headers['Access-Control-Allow-Methods'] = 'GET, POST';
      headers['Access-Control-Allow-Headers'] = 'Content-Type, X-CSRF-Token';
      headers['Access-Control-Max-Age'] = '600';
    }
    return headers;
  };
}

// Usage: const corsHeaders = corsFor(['https://portal.example.test']);
// Authentication, CSRF checks and object permission still run on every actual request.

```

## Derive CSP from actual document requirements

Prefer a strict policy over a host allowlist. The policy below allows a script only when it carries the response's nonce, and `'strict-dynamic'` extends that trust to scripts the trusted script loads, so the policy does not need to list CDNs, which often host files an attacker can abuse. The nonce must be unpredictable and freshly generated for every response; `NONCE` in the example is a placeholder. `base-uri 'none'` stops an injected `base` element from redirecting relative URLs, `object-src 'none'` removes plugin content, `form-action 'self'` stops forms posting elsewhere, `frame-ancestors 'none'` prevents framing, and `require-trusted-types-for 'script'` makes string assignments to sinks such as `innerHTML` throw unless a named Trusted Types policy produced the value.

Start with `Content-Security-Policy-Report-Only` and a `report-to` endpoint to discover legitimate dependencies, then move the reviewed policy into enforcement; report-only observations do not block attacks. Frameworks increasingly generate this for you: Django 6.0 added `ContentSecurityPolicyMiddleware`, the `SECURE_CSP` and `SECURE_CSP_REPORT_ONLY` settings and a `csp_nonce` template variable. The header block below passed Google's CSP Evaluator with no findings, and in Chrome the nonce'd script ran, an inline script without the nonce did not, a script loaded through a Trusted Types policy ran, a plain-string `innerHTML` assignment threw a `TypeError`, and the injected `base` element, a disallowed connection and a cross-origin form post were blocked. Do not add `'unsafe-inline'` or broad wildcards to silence a report without understanding which behavior needs them. To check your own header, paste it into /tools/csp-evaluator/; the CSP lab at /labs/csp-policy/ builds a strict policy step by step.

```http
# Document response headers. NONCE is fresh and unpredictable for every response.
Content-Security-Policy: default-src 'none'; script-src 'nonce-NONCE' 'strict-dynamic'; style-src 'self'; img-src 'self'; connect-src https://api.example.test; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'; require-trusted-types-for 'script'; trusted-types app-html; report-to csp
Reporting-Endpoints: csp="https://portal.example.test/csp-reports"

```

## Debug the layer that actually denied the operation

For CORS, compare the page's serialized origin, preflight response and actual response. A successful `curl` request cannot prove browser response availability, because `curl` does not enforce CORS. For CSP, inspect the policy delivered with the document and the browser's reported effective directive. A script allowed by CSP can still perform unsafe DOM operations, so maintain safe rendering controls underneath the policy.

Separate expected results into three columns: did the server receive the request, did it mutate state, and could page script read the response? Add a fourth column for document capability when testing CSP. This prevents a common misdiagnosis in which a browser console error is mistaken for a server rejection. Keep policy generation consistent across error pages, cached HTML and redirects; a well-protected main page does not establish that every document response carries the same controls.

## A real-world failure

CVE-2025-55462 was a CORS misconfiguration in Eramba that reflected the attacker's `Origin` header into `Access-Control-Allow-Origin` while also sending `Access-Control-Allow-Credentials: true`, letting any malicious site read a logged-in user's session data cross-origin. The missing control was an allowlist of trusted origins instead of reflecting the request's origin.

## Account for response caching and document reuse

CORS policy is expressed on the response the browser actually receives. If a shared cache stores a response generated for `portal.example.test` and reuses it for another origin without considering `Vary`, the resulting headers may be wrong even though the application code generated them correctly. Review cache keys, origin variation and authenticated-response caching together. `Vary: Origin` addresses origin-dependent response selection; it does not by itself make private user data safe for shared caching. Session or authorization-dependent data needs its own caching policy.

CSP nonces have a related caching concern. A server-rendered page containing nonce `N7` and a header requiring nonce `N8` will block its intended script. Reusing a complete personalized document and nonce broadly can also undermine the intended generation model. Keep header and document nonce generation coupled, and review how CDNs or fragment caches assemble the final response. Report-only telemetry can reveal mismatches before enforcement, but should not be mistaken for attack prevention.

When reducing breakage, change the narrow directive responsible for the legitimate dependency. If a required API endpoint is missing from `connect-src`, add the precise approved origin after review. Do not expand `script-src` to `'unsafe-inline'` because a network request failed; the directives govern different operations. The best debugging evidence identifies the document URL, effective directive, blocked destination and response policy, using synthetic or redacted values where URLs contain sensitive information.

## Guided practice

Apply supplied CORS and CSP policies to concrete browser operations.

### Supplied material

```text
API CORS allowlist={https://portal.example.test}; credentials allowed; GET/POST; headers Content-Type and X-CSRF-Token. API still requires session and object permission.
Document enforcement policy: script-src 'nonce-R7'; connect-src https://api.example.test; default-src 'none'. R7 is an illustrative nonce value.
A: portal credentialed GET of Alice's own invoice; server returns ACAO portal and ACAC true.
B: other.example.test simple form POST; server lacks CSRF protection and changes Alice's email; response has no ACAO.
C: portal credentialed GET; response ACAO=* and ACAC=true.
D: script with nonce R7. E: injected inline script with no nonce.
F: allowed script fetches https://collector.invalid/track.
G: portal reads Bob's invoice; server ownership rule denies.
```

### 1. Separate network and read outcomes

Compare A and B using receive/mutate/read columns.

Worked answer: A reaches the API, performs the allowed read and exposes its response to portal script. B reaches the API and mutates email, but the foreign script cannot read its response. B remains a CSRF failure despite the CORS denial.

### 2. Evaluate credentialed wildcard behavior

Decide C and explain the appropriate correction.

Worked answer: The browser refuses credentialed response sharing with `Access-Control-Allow-Origin: *`. Return the exact trusted portal origin after allowlist validation and `Vary: Origin`; do not reflect arbitrary origins.

### 3. Apply document capabilities

Decide D, E and F under the enforcing CSP.

Worked answer: D may execute because its nonce matches. E is blocked. F is blocked by `connect-src` even though the script itself was allowed. Under a report-only policy these violations would be reported without blocking.

### 4. Keep authorization independent

Evaluate G and state what removing CORS headers would change.

Worked answer: The server denies Bob's invoice based on ownership and releases no invoice data. CORS headers cannot make that decision, and non-browser clients could still call the API if server authorization were absent.

Deliverable: A browser decision table separating request receipt, state mutation, response sharing and document capabilities.

Scope: Paper exercise: policies and browser operations are supplied. `cors.mjs` and the header block above were checked in Chrome with local servers.

## Executable practice

- [CORS policy lab: Grant CORS to the right origins only](https://softwaresecurity.com/labs/cors-policy/)
- [CSP builder lab: Build a strict Content-Security-Policy](https://softwaresecurity.com/labs/csp-policy/)

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [MDN: CORS protocol behavior](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS)
- [MDN: Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP)
- [OWASP DOM XSS prevention](https://cheatsheetseries.owasp.org/cheatsheets/DOM_based_XSS_Prevention_Cheat_Sheet.html)
- [Fetch Standard: CORS-safelisted request headers](https://fetch.spec.whatwg.org/#cors-safelisted-request-header)
- [W3C Content Security Policy Level 3](https://www.w3.org/TR/CSP3/). W3C Working Draft, 16 September 2026.
- [web.dev: mitigate XSS with a strict CSP](https://web.dev/articles/strict-csp)
- [MDN: Trusted Types API](https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API)
- [Django: Content Security Policy](https://docs.djangoproject.com/en/6.1/ref/csp/). Django 6.1.
- [PortSwigger: CORS misconfigurations](https://portswigger.net/web-security/cors)
- [NVD: CVE-2025-55462, CORS misconfiguration in Eramba](https://nvd.nist.gov/vuln/detail/cve-2025-55462)