#React#DevOps

CORS and CSP: Two Lines of Defense for Web Security

CORS and CSP sound similar but serve different purposes: CORS governs cross-origin reads, while CSP controls what a page may load. This guide covers simple requests, preflights, credentials, CSP source and inline-script restrictions, common pitfalls, and a production-ready set of security headers.

Key Points

  • Topic Focus: React、DevOps
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

I have seen far too many people conflate CORS with CSP, assuming that configuring CSP means they can ignore CORS, or vice versa. More commonly, a backend developer will try to "fix a cross-origin error" by adding Access-Control-Allow-Origin: * to the response and calling it a day, leaving an opening through which any website can send requests with the user's credentials. The names sound similar, but their responsibilities are completely different: CORS is a negotiation mechanism through which the browser relaxes same-origin restrictions for you, while CSP is a security policy you use to proactively restrict what a page may load. One loosens external access; the other tightens internal permissions. This article explains both in depth and provides a set of security headers that every production deployment should have.

1. First, understand what CORS and CSP actually control

In one sentence:

  • CORS (Cross-Origin Resource Sharing): Browsers prohibit pages from reading cross-origin responses by default. CORS uses a set of server response headers to tell the browser, "This origin may read my responses." It is a mechanism for relaxing the same-origin policy.
  • CSP (Content Security Policy): Browsers allow pages to load resources from any origin by default. CSP uses a response header to tell the browser, "Only load scripts, styles, and images from these origins." It is a policy for restricting resource loading.

One faces outward and the other inward; one negotiates access, while the other defines an allowlist of constraints. They are discussed together only where they overlap in XSS attack scenarios: CSP prevents injected malicious scripts from executing, while CORS prevents malicious sites from reading your API. Your baseline Web security is not adequate until both lines of defense are configured correctly.

2. How CORS works underneath: what does the same-origin policy actually block?

The Same-Origin Policy is an iron rule enforced by browsers: the protocol + domain + port must all match for two resources to have the "same origin." By default, JavaScript cannot read responses from cross-origin requests. Note three details:

  1. It blocks "reading," not "sending." Cross-origin <img>, <script>, and <form> requests have already been sent; JavaScript simply cannot read their results. CORS essentially creates an opening for JavaScript requests such as fetch / XHR that need to read responses.
  2. The browser enforces it, not the server. The server still receives and processes the request; the browser merely withholds the response from JavaScript. Therefore, "intercepting CORS on the backend" is a misconception. You must either return the correct CORS headers at the gateway or use a same-origin proxy from the frontend.
  3. Simple requests and preflighted requests take two different paths, which is where beginners most often run into trouble.

3. Simple requests vs. preflighted requests: what is the browser blocking for you?

Browsers divide cross-origin requests into two categories:

Simple requests: The method is GET/HEAD/POST, the headers are limited to a small safelist (Accept, Content-Type values such as text/plain/multipart/form-data/application/x-www-form-urlencoded, and so on), and there are no custom headers. The browser sends these requests immediately; the server only needs to include the CORS headers in its response.

Preflighted requests (Preflight): As soon as a request includes a custom header (Authorization is acceptable, but headers such as X-Requested-With and X-CSRF-Token are not), uses a method other than the three above, or sets Content-Type to application/json, the browser first sends an OPTIONS request to test the route:

text
OPTIONS /api/orders HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization

The server must respond to this OPTIONS request correctly, telling the browser, "I allow this method and these headers," before the browser will send the actual request. There are two especially common failures: the backend framework treats OPTIONS as a 404 because no route was registered, or the gateway intercepts OPTIONS outside the authentication middleware without returning CORS headers. When the frontend reports a failed CORS preflight, nine times out of ten it is one of these two problems.

A preflight adds another round trip. You can use Access-Control-Max-Age: 86400 to let the browser cache the result, preventing identical requests from repeating the preflight for one day.

4. The four critical CORS headers and the major credentials trap

Understanding these response headers is enough for 90% of use cases:

text
Access-Control-Allow-Origin:  https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400

There is one rule you must burn into your memory: when Access-Control-Allow-Credentials: true is enabled (with frontend code such as fetch(url, { credentials: 'include' }) or Axios's withCredentials: true), Access-Control-Allow-Origin must never be *. It must be a specific value that exactly matches the request's Origin.

Why? Credentials mean the request automatically includes the user's Cookie. Allowing * would mean that any website could use the user's authenticated session on your site to send requests to your API and read the responses. That is the classic combination of CSRF and data leakage. Browsers enforce this rule strictly: if they see both Credentials: true and Allow-Origin: *, they reject the response and prevent it from being read.

The correct approach is for the backend to read the request's Origin header, look it up in an allowlist, and return that specific value when it matches:

js
const allowed = new Set(['https://app.example.com', 'https://admin.example.com']);
const origin = req.headers.origin;
if (allowed.has(origin)) {
  res.setHeader('Access-Control-Allow-Origin', origin);
  res.setHeader('Vary', 'Origin');          // Do not forget this, or CDN caches will leak across origins
  res.setHeader('Access-Control-Allow-Credentials', 'true');
}

Vary: Origin is another frequently omitted header. Without it, a CDN may cache the CORS response for one origin and serve it to every other origin, causing cross-origin cache leakage. If you enable credentials: true, you must also configure an allowlist + Vary: Origin. That is the minimum acceptable standard.

5. CSP: restricting what a page may load

CSP is an allowlist policy delivered through a response header. The simplest example is:

text
Content-Security-Policy: default-src 'self';

This means that, by default, the page may load all resources only from the same origin: scripts, styles, images, fonts, connections, frames, and so on. default-src is the fallback; any specific directive you omit falls back to it. Common directives include:

  • script-src: JavaScript sources, the core of XSS protection
  • style-src: CSS sources
  • img-src / font-src / connect-src (fetch/WebSocket destinations) / frame-src
  • object-src 'none': Disables Flash/plugins and should almost always be set

A CSP for a real application might look like this:

text
Content-Security-Policy:
  default-src 'self';
  script-src 'self' https://cdn.example.com;
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  connect-src 'self' https://api.example.com https://*.sentry.io;
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';

frame-ancestors 'none' conveniently replaces the old X-Frame-Options: DENY and prevents clickjacking. base-uri 'self' prevents an injected <base> tag from altering URL resolution.

6. Blocking inline code with nonce/hash: CSP's real weapon against XSS

The core of CSP's XSS protection is that it blocks inline scripts by default. Even if an attacker injects <script>steal()</script> into your page's DOM, CSP prevents that script from executing at all. This is fundamentally how it stops injection attacks.

But blocking inline code may conflict with your existing application. Your React app might contain an inline <script> for bootstrap data, a third-party SDK may require inline code, or a legacy page may contain many onclick= handlers. CSP provides three ways to permit them:

  • 'unsafe-inline': Allows all inline code. Once you add it, CSP's ability to defend against XSS is effectively neutralized; it can only block externally loaded resources. You may use it temporarily during the initial rollout, but removing it must be the goal.
  • nonce: The server generates a random string for every response and places it in both the CSP header and the inline script's nonce attribute. Only inline scripts carrying that nonce may execute.
text
Content-Security-Policy: script-src 'self' 'nonce-3q8c2r9b'
html
<script nonce="3q8c2r9b">console.log('ok')</script>
  • hash: Calculates a sha256 hash of the inline script's contents and includes it in the CSP. This is suitable for inline code with fixed contents.

Modern frameworks such as Next.js / Nuxt can automatically add nonces to inline scripts, making CSP straightforward to configure. The goal is for script-src to contain neither 'unsafe-inline' nor 'unsafe-eval'. That is when CSP provides its real protection.

7. Common CORS and CSP pitfalls

CORS pitfalls:

  • Allow-Origin: * + Credentials: true: As explained above, this is a direct vulnerability. Although browsers reject it, some gateways/proxies "helpfully" replace the wildcard with a specific Origin while retaining Credentials: true, effectively breaking the allowlist logic. Make sure you control this behavior yourself.
  • Not handling OPTIONS: The framework's default router does not take ownership of OPTIONS, causing the preflight to return 404.
  • Preflight caching is too short: Every request is preceded by an OPTIONS request, directly doubling API response time. Set Access-Control-Max-Age.
  • Treating CORS as authentication in an SPA: CORS is only browser isolation, not authentication. curl / native clients are not constrained by CORS. Real authorization checks must happen on the server; do not expect the browser to block unauthorized access for you.

CSP pitfalls:

  • Overly strict policies break the application: You start with default-src 'self', and suddenly third-party fonts, analytics SDKs, and inline styles all fail, leaving the page completely unstyled. Every required third-party domain must be added to the allowlist individually.
  • Omitting 'unsafe-inline' from style-src: Many frameworks, including older Tailwind versions and the runtime mode of styled-components, require inline styles. Blocking them immediately can remove all styling. You can begin with 'unsafe-inline' and migrate to nonces later.
  • Leaving WebSocket / Sentry / analytics reporting domains out of connect-src: Frontend fetch calls fail with ERR_BLOCKED_BY_RESPONSE, but the page itself shows no error; the request fails quietly in the console, making diagnosis painfully slow. Always inspect CSP violation warnings in the Console during debugging.

8. The essential production security headers

Regardless of your application's size, these headers are the minimum baseline. Add them in Nginx, Next.js headers(), or Vercel's headers configuration:

text
# Enforce HTTPS + preload (confirm the entire site supports HTTPS before adding preload)
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload

# Prevent MIME sniffing; a legacy of the IE era that remains necessary
X-Content-Type-Options: nosniff

# Prevent clickjacking (CSP frame-ancestors is the modern replacement; this is a fallback)
X-Frame-Options: DENY

# Control how much Referer information is exposed
Referrer-Policy: strict-origin-when-cross-origin

# Disable browser features driven by cross-domain policy files
X-Permitted-Cross-Domain-Policies: none

# CSP (the progressive rollout process is covered below)
Content-Security-Policy: ...

Once HSTS is configured, browsers force all access to your site to use HTTPS for the duration of max-age, preventing an attacker in the middle from downgrading users to HTTP and hijacking the connection. Conversely, before enabling HSTS, you must confirm that the entire site, including all subdomains, supports HTTPS. Otherwise, subdomains that still use HTTP will become completely inaccessible. Be even more cautious with the preload list: once submitted, it is effectively built into browser source code, and removal requires a formal process.

9. Roll out CSP progressively: report-only → observe → tighten → enforce

Enforcing CSP immediately is a high-risk operation. If a single domain is missing from the allowlist, some production feature will silently stop working. The correct approach has four steps:

Step 1: Report-Only mode. Change the header name to Content-Security-Policy-Report-Only and define the policy normally, but the browser reports violations without blocking them. Violations are sent to the endpoint you specify:

text
Content-Security-Policy-Report-Only:
  default-src 'self';
  report-uri /csp-report;
  report-to csp-endpoint;

Step 2: Collect and observe. Every violation received by /csp-report contains a document-uri, violated-directive, and blocked-uri. Compile every blocked domain into a table and determine individually whether each one is required by the application.

Step 3: Tighten the policy. Add the observed legitimate domains to the allowlist, remove 'unsafe-inline', and introduce nonces until Report-Only produces almost no new reports.

Step 4: Switch to enforcement. Change the header name back to Content-Security-Policy and retain report-uri for ongoing monitoring. Any new third-party domain introduced later will immediately appear in the reports, allowing you to detect it and update the allowlist promptly.

This "observe first, then tighten" process is the only reliable way to deploy CSP safely in production without causing incidents. Anyone who copies a CSP header and pushes it directly to production will probably cause a problem within a week.

Summary

  1. CORS relaxes the same-origin policy, while CSP restricts resource loading. The former negotiates external access; the latter enforces an internal allowlist. Do not conflate them.
  2. CORS's fatal combination is credentials: true + Allow-Origin: *. Browsers reject it, but do not leave this defense to the browser. Lock it down yourself with an Origin allowlist + Vary: Origin.
  3. CSP's real weapon against XSS is blocking inline scripts. Your goal must be to eliminate 'unsafe-inline' and 'unsafe-eval', replacing them with nonce/hash allowlisting.
  4. The essential production headers (HSTS / nosniff / X-Frame-Options / Referrer-Policy / CSP) form the baseline of Web security. Missing any one of them leaves you exposed.
  5. CSP must be rolled out progressively: observe with Report-Only → complete the allowlist → eliminate unsafe-inline → enforce. Enforcing it immediately is self-sabotage.

The underlying logic of Web security fits into one sentence: restrict by default, permit only when necessary, and be able to explain every permission you grant. CORS and CSP are two sides of that principle. One makes you explain "why this cross-origin site may read my data," while the other makes you explain "why this script may run on my page." Any permission you cannot explain will eventually become the root cause of a production incident.

Related

Related Content