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.

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

Browser CORS and CSP configuration. Neither policy authorizes server-side actions or makes unsafe HTML safe.

Part of
Learning path: Browser and API boundaries (step 3 of 18). Workflows: Fix a finding, Secure a feature

Fix it in your stack

Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

Learning goals and prerequisitesAbout 25 minutes: 7 to read and 18 for the exercise

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.

Before you start

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

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

Refresh first: Prevent XSS at the output boundary

Go to the guided practice

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

Fixedcors.mjsJavaScript
// 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.

Configurationresponse headersHTTP
# 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 scenario material
Plain 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. 1. Separate network and read outcomes

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

    Expected result and reasoning

    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. 2. Evaluate credentialed wildcard behavior

    Decide C and explain the appropriate correction.

    Expected result and reasoning

    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. 3. Apply document capabilities

    Decide D, E and F under the enforcing CSP.

    Under the enforcing CSP

    D: script with nonce R7
    E: injected inline script with no nonce
    F: the allowed script fetches https://collector.invalid/track

    Expected result and reasoning

    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. 4. Keep authorization independent

    Evaluate G and state what removing CORS headers would change.

    Expected result and reasoning

    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.

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

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

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Repair the code

CORS policy lab: Grant CORS to the right origins only

CSP builder lab: Build a strict Content-Security-Policy

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. MDN: CORS protocol behavior
  2. MDN: Content Security Policy
  3. OWASP DOM XSS prevention
  4. Fetch Standard: CORS-safelisted request headers
  5. W3C Content Security Policy Level 3. W3C Working Draft, 16 September 2026.
  6. web.dev: mitigate XSS with a strict CSP
  7. MDN: Trusted Types API
  8. Django: Content Security Policy. Django 6.1.
  9. PortSwigger: CORS misconfigurations
  10. NVD: CVE-2025-55462, CORS misconfiguration in Eramba

Reference information

SDLC phase
Development
ASVS 5.0.0 requirements
v5.0.0-3.4.2 · v5.0.0-3.4.3 · v5.0.0-3.4.6 · v5.0.0-3.4.7 · v5.0.0-3.5.2
OWASP WSTG v4.2 tests
WSTG-CLNT-07

Download this guide as Markdown