Content Security Policy and Cross-Origin Resource Sharing are both response headers the browser enforces, and teams regularly deploy one believing it does the job of the other. CSP restricts what your own page is allowed to load and execute, so it protects your users from content injected into your origin. CORS decides which other origins may read responses from your server, so it loosens the same-origin policy on purpose. One narrows, the other widens, and they point in opposite directions. This guide compares them as controls: the threat each one addresses, how each works in the browser, how each fails, how to test them, and how to decide what each application needs. It is written for application security engineers, front-end and API leads, and assessors reviewing header configurations. For directive-level CSP detail see the Content Security Policy Guide; for CORS flaws in depth see the CORS Misconfiguration Guide.
Key takeaways
- Opposite directions. CSP is a restriction your page places on itself (what it may load, run, frame and post to); CORS is a permission your server grants to other origins to read its responses.
- CORS does not stop XSS, and CSP does not protect APIs. CORS has no effect on script injected into your own origin; CSP on an HTML page does nothing to stop another site reading a JSON API that returns permissive CORS headers.
- CORS governs reading, not sending. The Fetch Standard describes CORS as headers that indicate "whether a response can be shared cross-origin". Simple cross-origin requests are still sent, so CSRF defences remain necessary.
- The worst misconfigurations are permissive by design. For CORS: reflecting any Origin with credentials, allowlisting
nulland unanchored pattern matches. For CSP:'unsafe-inline', broad host allowlists and a policy left in Report-Only mode for good. - Both are defence in depth. The CSP Level 3 draft says CSP "is not intended as a first line of defense"; OWASP warns the Origin header can be spoofed outside the browser. Output encoding and server-side authorisation still do the primary work.
The short answer
Deploy CSP on every HTML response your application serves, aiming for a strict nonce-based or hash-based policy with object-src 'none', base-uri 'none' and an explicit frame-ancestors. Its job is to limit the damage when an injection flaw slips through. Configure CORS only on endpoints that genuinely need to be read by a browser running on another origin, with an exact-match allowlist of origins, and leave it off everywhere else. Its job is to grant the narrowest possible exception to the same-origin policy. If you are asking "which one should we use", the question is usually mistaken: most web applications need a strict CSP, and many need no CORS at all. Read the Same-Origin Policy and Browser Security Model Guide first if the baseline rules are unfamiliar.
Side by side
| Dimension | Content Security Policy (CSP) | Cross-Origin Resource Sharing (CORS) |
|---|---|---|
| Effect on the same-origin policy | Adds restrictions beyond it | Relaxes it for named origins |
| Who is protected | Users of your page, against content running in your origin | Your server's data, against being read by pages on other origins |
| Set on | HTML documents (and workers) | Resources read by cross-origin scripts, usually APIs, fonts, data files |
| Main headers | Content-Security-Policy, Content-Security-Policy-Report-Only, Reporting-Endpoints | Access-Control-Allow-Origin, -Allow-Credentials, -Allow-Methods, -Allow-Headers, -Expose-Headers, -Max-Age, plus Vary: Origin |
| Default when absent | No extra restriction: the page loads whatever its markup asks for | Most restrictive: cross-origin scripts cannot read the response |
| Attacks addressed | XSS (CAPEC-63), clickjacking (CAPEC-103) via frame-ancestors, data exfiltration via connect-src and form-action, mixed content | None directly: it is an access grant. Misconfiguration creates cross-origin data theft (CWE-942) |
| Typical failure | Policy too loose to stop an injected script | Policy so loose that an attacker's site can read authenticated responses |
| Testing reference | OWASP WSTG-CONF-12 | OWASP WSTG-CLNT-07 |
| ASVS 5.0 requirements | V3.4.3, V3.4.6, V3.4.7 | V3.4.2, V3.5.1, V3.5.2 |
| Specification | W3C CSP Level 3 (Working Draft, 27 October 2026, checked 14 November 2026) | WHATWG Fetch Standard, CORS protocol section (Living Standard) |
The "default when absent" row is the most useful single fact in this table. Missing CSP is a lost safety net; missing CORS is usually the safe state. That asymmetry is why a header scanner that reports "CORS not configured" as a finding is wrong, and one that reports "CSP not configured" is right.
CSP: threat model and architecture
Threat model: CSP assumes an attacker has found a way to put markup or script into a page served from your origin: a stored or reflected XSS flaw, a DOM sink fed from the URL, a compromised third-party script, or an injected <base> tag that redirects relative script loads. Once injected code runs in your origin it has your users' session, your DOM and your same-origin API access, which is why ATT&CK lists outcomes such as T1185 Browser Session Hijacking and T1189 Drive-by Compromise. CSP does not find or fix the injection. It reduces what the injected content can do.
Architecture in words: the server sends a policy with the document. The browser parses it into directives, and before every fetch, script execution, frame load or form submission it checks the action against the relevant directive. Fetch directives (script-src, connect-src, img-src and others, falling back to default-src) control what loads. Document directives such as base-uri constrain the page itself. Navigation directives (form-action, frame-ancestors) control where the page can submit and who can frame it. Violations are blocked and, if report-to names an endpoint declared in Reporting-Endpoints, reported as csp-violation reports.
Strict versus allowlist: Google's strict CSP guidance states that URL allowlist policies need heavy customisation and can be bypassed in most configurations, because any allowlisted host that serves JSONP endpoints or user-controlled files becomes a script gadget. A strict policy instead trusts scripts by a per-response nonce or by hash:
- Nonce-based:
script-src 'nonce-{random}' 'strict-dynamic'; object-src 'none'; base-uri 'none', for server-rendered pages. The nonce must be random and unique per response, and OWASP advises applying it through the templating engine rather than middleware that tags every script. - Hash-based: the same policy with SHA-256 hashes of each trusted inline script, for static or cached pages. Any change to the script, including whitespace, changes the hash.
- Fallbacks:
https:and'unsafe-inline'may be added for very old browsers; browsers that support nonces and'strict-dynamic'ignore them.
Beyond scripts: frame-ancestors replaces X-Frame-Options for clickjacking defence, which ASVS 5.0 V3.4.6 now describes as obsolete. require-trusted-types-for 'script' makes DOM injection sinks reject plain strings; see the Trusted Types and DOM XSS Prevention Guide. upgrade-insecure-requests rewrites HTTP subresource loads to HTTPS but does not replace HSTS.
CORS: threat model and architecture
Threat model: CORS exists because the same-origin policy blocks legitimate designs, such as a single-page application on app.example.com calling an API on api.example.com. The threat it introduces, rather than mitigates, is a malicious page on another origin using a victim's browser, and the victim's cookies or ambient network position, to read data the attacker could not fetch directly. The Fetch Standard warns that "extreme care has to be taken to avoid the confused deputy problem" when sharing credentialed responses. The browser is the deputy, acting with the user's authority on behalf of whichever page is open.
Architecture in words: a script on origin A issues fetch() or XMLHttpRequest to origin B. The browser attaches an Origin header. For a simple request (GET, HEAD or POST, only CORS-safelisted headers, and a Content-Type of application/x-www-form-urlencoded, multipart/form-data or text/plain) the browser sends the request immediately and then checks the response for Access-Control-Allow-Origin before letting the script read it. Anything else, such as a PUT, a DELETE, a custom header or a JSON content type, triggers an OPTIONS preflight carrying Access-Control-Request-Method and Access-Control-Request-Headers; the real request is sent only if the preflight response permits it. Preflight results may be cached for Access-Control-Max-Age seconds, subject to a browser-imposed ceiling.
Credentials rules: for a credentialed request the server must name an explicit origin, not *, in Access-Control-Allow-Origin, must send Access-Control-Allow-Credentials: true (the Fetch Standard requires exactly that value), and cannot use * in the allow-methods, allow-headers or expose-headers lists. When the allowed origin is chosen dynamically from a list, the response must carry Vary: Origin so shared caches do not serve one origin's grant to another. Third-party cookie restrictions in modern browsers can block credentialed cross-site requests even when the server permits them, which is a reason to prefer token-based authentication for genuinely cross-site APIs; see Session Cookies vs Bearer Tokens.
What CORS is not: OWASP's HTML5 Security Cheat Sheet states that "CORS does not prevent the requested data from going to an unauthorized location" and that standard CSRF prevention is still needed. The request has already reached your server by the time the browser decides whether the script may read the answer. Outside a browser, Origin is just a header any client can set, so CORS is never an authorisation control for non-browser callers.
Where each one breaks
| Failure mode | Control | What happens | Fix |
|---|---|---|---|
'unsafe-inline' in script-src without a nonce or hash | CSP | Injected inline scripts and event handlers run; the policy blocks little that matters | Move inline code to files or nonce it; replace onclick attributes with addEventListener |
Broad host allowlist (https:, *.cdn.example, public CDNs) | CSP | Any JSONP endpoint or user-uploaded file on an allowed host becomes an execution route | Nonce or hash with 'strict-dynamic'; keep host lists short and reviewed |
Missing object-src and base-uri | CSP | Plugin content or an injected <base> tag sidesteps script restrictions | Set both to 'none', as ASVS V3.4.3 requires at minimum |
Report-Only forever, or frame-ancestors in a meta tag | CSP | Nothing is enforced; meta-delivered policies ignore frame-ancestors, report-uri and sandbox | Set an enforcement date; deliver policy as a response header |
| Static or reused nonce | CSP | An attacker who reads one page can reuse the nonce in injected markup | Generate a cryptographically random nonce per response; never cache nonced HTML |
| Origin reflection with credentials | CORS | Any site the victim visits can read authenticated API responses | Exact-match allowlist; never echo Origin unchecked |
| Unanchored pattern match | CORS | A check for "contains example.com" accepts example.com.attacker.test | Compare the full serialised origin (scheme, host, port) against a list |
null origin allowlisted | CORS | Sandboxed iframes and some redirects send Origin: null, which an attacker can produce at will | Never allowlist null |
* on a response carrying sensitive data | CORS | Data protected only by network position (intranet, VPN, IP allowlist) is readable from any site a user visits | Reserve * for truly public, unauthenticated data, as ASVS V3.4.2 requires |
Missing Vary: Origin | CORS | A cache stores one origin's grant and serves it to others, or breaks legitimate callers | Send Vary: Origin whenever the allowed origin is computed |
A pattern links the two columns: both controls fail by trusting too much, and both usually loosen under delivery pressure. A developer adds 'unsafe-inline' to make a widget work, or reflects the Origin header to stop a console error, and the change survives because nothing breaks.
How CSP and CORS interact
- connect-src limits your page; CORS limits theirs. For your page's script to read a cross-origin API, both must agree: your CSP
connect-srcmust allow the API origin, and the API's CORS headers must allow your page's origin. A failure on either side shows up as a console error, and teams sometimes "fix" the wrong one. - XSS defeats CORS allowlists. If
app.example.comis on your API's CORS allowlist and suffers XSS, the injected script runs as that trusted origin. Every origin on a credentialed allowlist is effectively part of your API's attack surface, and a strict CSP on those origins protects the API too. - CSP can contain exfiltration. A tight
connect-src,img-srcandform-actionlimits where stolen data can be sent from a compromised page. This is one of CSP's main roles against web skimming; see the Web Skimming Incident Playbook. - Preflight is not CSRF protection by default. ASVS 5.0 V3.5.2 requires that, if you rely on preflight to block cross-origin use of sensitive functions, the function cannot be reached with a request that skips preflight, for example by checking
Content-Typeor requiring a non-safelisted header. V3.5.1 requires anti-forgery tokens or equivalent where you do not rely on preflight. See the CORS vs CSRF Protection guide.
Worked scenario
A retailer runs a server-rendered storefront on www.shop.example, a single-page account area on account.shop.example and a JSON API on api.shop.example using session cookies. A partner site partner.example displays public stock levels. An assessment finds three things.
- CORS on the API reflects any Origin with credentials. This lets any site a logged-in customer visits read their order history. The fix is an allowlist of exactly
https://account.shop.examplefor credentialed routes,Vary: Origin, and a separate unauthenticated/public/stockroute that returnsAccess-Control-Allow-Origin: *with no cookies and no personal data for the partner. - The storefront CSP is
script-src 'self' 'unsafe-inline' https:. This would not stop a stored XSS in product reviews. The team ships a Report-Only strict policy with nonces generated in the template layer, triages violations for two release cycles, refactors 40 inline handlers, then enforces.connect-srcis limited to the API and the analytics origin, andform-action 'self'stops injected forms posting card data elsewhere. - The account area has no
frame-ancestors. Addingframe-ancestors 'none'closes a clickjacking route on the "change email" page; see the Clickjacking Guide.
The lesson is the interaction: the CORS fix narrows who can read the API, and the CSP fix protects the one origin still trusted to read it. The numbers here are illustrative.
Decision criteria
| Situation | CSP | CORS |
|---|---|---|
| Server-rendered site, same-origin API | Strict nonce policy | None needed |
| Static SPA from CDN calling API on another subdomain | Hash-based strict policy; connect-src for the API | Exact-origin allowlist; credentials only if cookies are used |
| Public open data API | Not relevant to JSON responses beyond a restrictive default | * without credentials, no sensitive fields |
| Partner integrations reading customer data | Strict policy on every allowlisted origin you control | Prefer server-to-server calls with tokens; if browser-based, exact allowlist per partner, reviewed |
| Internal tools on a corporate network | Strict policy | None, and never *: network position is not authentication |
| Heavy third-party tag estate (marketing scripts) | Nonce with 'strict-dynamic', accepting that trust propagates to loaded scripts | Not relevant |
Small versus large organisations: a small team gets most of the value from a framework's built-in CORS middleware configured with a literal origin list and a hash-based CSP generated at build time. Large estates need central ownership: a shared CORS configuration library with tests that reject reflection and null, a CSP reporting pipeline with violation triage, and policy templates per application type.
Rolling out and testing
- Inventory: list every endpoint that returns
Access-Control-Allow-*headers and every HTML response with or without CSP. Gateways, CDNs and frameworks often add CORS headers globally, so test the live responses, not the code. - Remove unneeded CORS: delete grants that no browser client uses. Replace reflection with a literal list and add
Vary: Origin. - Deploy CSP in Report-Only: send the strict policy as
Content-Security-Policy-Report-Onlywithreport-toand, for older browsers,report-uri, which MDN describes as deprecated but still worth declaring alongside. - Refactor: remove inline handlers,
javascript:URLs andeval(); check the policy with a CSP evaluator. - Enforce in rings: enforce on low-traffic pages first, keep the Report-Only header alongside for the next, stricter version.
- Test continuously: follow WSTG-CLNT-07 and WSTG-CONF-12 in assessments and add automated checks in CI: send crafted
Originvalues (a foreign domain, a suffix variant,null) and assert that no credentialed grant comes back; parse the CSP and fail the build on'unsafe-inline'without a nonce, missingobject-srcorbase-uri, or a missingframe-ancestors. - Roll back safely: CSP rollback means reverting to the previous enforced policy while keeping the new one in Report-Only, never removing CSP. CORS rollback means restoring a specific origin to the list, never re-enabling reflection.
Detection and measurement
- Strict CSP coverage: percentage of HTML responses with an enforced policy containing a nonce or hash,
object-src 'none'andbase-uri 'none'. - Violation trend: CSP violation reports per day by directive and page, split into expected (extensions, legacy code) and suspicious (inline script on a page with user content).
- Credentialed CORS grants: number of endpoints returning
Access-Control-Allow-Credentials: trueand the number of origins each allows; this should fall and stay small. - Dangerous CORS patterns: count of endpoints that reflect arbitrary origins or accept
null, target zero. - Report-Only age: days each Report-Only policy has run without enforcement.
Treat a CSP report showing an inline script blocked on a page that renders user content as a possible XSS attempt, not noise. Reports should not carry tokens in URLs, so strip query strings at the collector.
Framework mapping
| Framework | CSP | CORS |
|---|---|---|
| OWASP ASVS 5.0 | V3.4.3 CSP with object-src 'none' and base-uri 'none' (L2; per-response nonce or hash at L3); V3.4.6 frame-ancestors (L2); V3.4.7 violation reporting (L3) | V3.4.2 fixed or allowlisted Access-Control-Allow-Origin (L1); V3.5.1 and V3.5.2 request forgery and preflight reliance (L1) |
| OWASP WSTG | WSTG-CONF-12 | WSTG-CLNT-07 |
| CAPEC | CAPEC-63 Cross-Site Scripting, CAPEC-103 Clickjacking | CAPEC-111 JSON Hijacking (cross-origin data reading) |
| CWE | CWE-79, CWE-1021 | CWE-942 Permissive Cross-domain Policy with Untrusted Domains |
| NIST SP 800-53 Rev. 5 | SI-10 Information Input Validation (CSP as a compensating layer), SA-11 Developer Testing and Evaluation | SA-11, plus application access enforcement for the data behind the API |
Common failure patterns
- Using CORS to "secure" an API: restricting
Access-Control-Allow-Originand calling it access control. Scripts, servers and attackers' tools ignore it; authenticate and authorise every request. See the API Security Guide. - Expecting CORS to stop XSS: CORS has no effect on script injected into your own origin. Encode output and deploy CSP; see the Cross-Site Scripting (XSS) Prevention Guide.
- Copying a framework's permissive default to production: development configurations that allow all origins, or reflect them, ship unchanged.
- A CSP that lists every vendor's CDN: each new tag adds a host and the policy drifts into an allowlist that blocks nothing an attacker needs.
- Headers set in two layers: the application and the CDN or gateway both emit CORS or CSP headers, producing duplicates or conflicts that behave differently per browser.
- Trusting all subdomains: a regex for any subdomain lets a forgotten marketing site, or a subdomain takeover, inherit credentialed API access.
Practitioner checklist
- Every HTML response sends an enforced CSP header with a nonce or hash,
object-src 'none',base-uri 'none'andframe-ancestors. - No
'unsafe-inline'or'unsafe-eval'is relied on inscript-src; inline handlers are refactored. - Nonces are random per response and nonced HTML is not cached.
- CSP reporting uses
Reporting-Endpointsandreport-to, and violations are triaged, not just stored. - CORS is enabled only on endpoints with a real cross-origin browser client.
- Allowed origins are an exact-match list; no reflection, no
null, no unanchored patterns, withVary: Originset. *is used only on unauthenticated, non-sensitive responses.- CSRF protection is in place regardless of CORS settings.
- CI tests send crafted
Originvalues and parse CSP on every build. - One layer owns each header: application, gateway or CDN, documented.
Standards and references
- W3C: Content Security Policy Level 3
- WHATWG: Fetch Standard (CORS protocol)
- OWASP: WSTG-CONF-12 Test for Content Security Policy
- OWASP: WSTG-CLNT-07 Testing Cross Origin Resource Sharing
- OWASP: Content Security Policy Cheat Sheet
- OWASP: HTML5 Security Cheat Sheet
- MDN: Cross-Origin Resource Sharing (CORS)
- web.dev: Mitigate cross-site scripting with a strict Content Security Policy
Related NHI Mgmt Group resources: Security Headers Guide, Content Security Policy Guide, CORS Misconfiguration Guide, Same-Origin Policy and Browser Security Model Guide, XSS vs CSRF.