CORS vs CSP connect-src Explained

Two separate browser mechanisms can stop a fetch() to another origin, and they are constantly mistaken for one another. Content-Security-Policy’s connect-src decides whether the page is even allowed to make the request; CORS decides whether the page is allowed to read the response after the server answers. They live on different servers, produce different console messages, and require different fixes.

This page is part of CORS & Related Security Header Interactions, which maps how CORS relates to the other cross-origin security layers.

The Two Error Strings

A CSP connect-src violation (Chrome):

Refused to connect to 'https://api.example.com/v1/data' because it violates the
following Content Security Policy directive: "connect-src 'self' https://cdn.example.com".

A CORS failure (Chrome):

Access to fetch at 'https://api.example.com/v1/data' from origin 'https://app.example.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the
requested resource.

If the message contains “Refused to connect” and names a Content Security Policy directive, it is CSP — the request never left the browser. If it says “blocked by CORS policy”, the request was sent and the response was rejected.

Root Cause

The two mechanisms gate different steps of the same request. Content-Security-Policy is defined by the W3C CSP specification and is enforced on the requesting document: connect-src is an allowlist of destinations the page’s scripts may open connections to (via fetch, XMLHttpRequest, WebSocket, EventSource, sendBeacon). The browser checks it before dispatching the request. CORS, defined by the WHATWG Fetch Standard, is enforced after the server responds: it governs whether JavaScript may read a cross-origin response. Because CSP fires first, a connect-src block means the API server never even receives the request — so no amount of server-side Access-Control-Allow-Origin configuration will resolve it.

connect-src also has a fallback that catches people out: if the policy never mentions it, the browser falls back to default-src. A policy as innocuous as default-src 'self' therefore blocks every cross-origin fetch() on the page without the string connect-src appearing anywhere in the header. The directive governs fetch(), XMLHttpRequest, WebSocket, EventSource and navigator.sendBeacon(); it does not govern <img>, <script>, <link> or <iframe> loads, which are matched by img-src, script-src, style-src and frame-src instead. That distinction matters when a font or an image fails to load and the team reaches for the CORS configuration: if the console names font-src rather than connect-src, neither gate on this page is the one that fired.

Laid out end to end, the two gates sit on opposite sides of the network, each owned by a different server:

Two gates on one cross-origin request A left-to-right pipeline. A page script calls fetch, which must clear the CSP connect-src gate before the request reaches the wire, then clear the CORS response gate before JavaScript may read the body. Each gate drops out to its own console message and its own server-side fix. policy source: the page's own Content-Security-Policy header policy source: the API's own Access-Control-* response headers page script calls fetch() GATE 1: connect-src checked before the socket to the API is opened request on the wire GATE 2: CORS check applied after the server has already answered JS reads the response body "Refused to connect ..." fix the page's CSP header "blocked by CORS policy" fix the API's response headers Neither gate can be opened from the other side: CSP lives on the page, CORS lives on the API

Prerequisite State

Step-by-Step

Step 1 — Identify which gate fired

Read the console message. “Refused to connect … Content Security Policy” → CSP. “blocked by CORS policy” → CORS. This determines which server you fix. Note the second half of a CSP message as well: it echoes the directive that actually matched, so a message ending in "default-src 'self'" tells you the policy has no connect-src at all and you are looking at the fallback.

Each message fingerprint routes to exactly one owner and one place to edit:

Routing a console message to its owner A matrix with three columns: the console message fingerprint, the gate that refused the request, and the server that owns the fix. The first row covers the CSP refusal, the second row covers the CORS block, and the two rows point at different servers. Console message fingerprint Gate that refused Server that owns the fix Refused to connect to 'https://api.example.com/...' because it violates the following CSP directive connect-src nothing was sent; no network entry The host that serves the HTML: add the API origin to connect-src on the document response Access to fetch at 'https://api.example.com/...' has been blocked by CORS policy CORS response check request was sent and answered The API host: emit an exact Access-Control-Allow-Origin plus Vary: Origin A CSP refusal leaves no row in the Network panel at all — that absence is the fastest confirmation

Step 2a — Fix a CSP connect-src block

Add the API origin to the connect-src directive in the Content-Security-Policy header served by the page’s origin:

# On the server that serves the HTML page (not the API)
add_header Content-Security-Policy
  "default-src 'self'; connect-src 'self' https://api.example.com" always;

Step 2b — Fix a CORS block

Add the correct Access-Control-Allow-Origin on the API server:

// On the API server that receives the fetch
res.setHeader('Access-Control-Allow-Origin', 'https://app.example.com');
res.setHeader('Vary', 'Origin');

Step 3 — Re-test both gates

A request can clear CSP and then hit CORS. Fix one, reload, and read the next error. Only when neither message appears is the request fully permitted. Expect the second error to appear after the first fix lands — that is progress, not a regression, and a team that reverts the CSP change because “a new error showed up” ends up chasing the same loop twice.

Treat the request as a small state machine that only advances one step per deploy:

Clearing the two gates one state at a time Three states connected left to right. Editing connect-src on the page moves the request from the CSP-blocked state to the CORS-blocked state, and editing Access-Control-Allow-Origin on the API moves it to the permitted state. A return edge shows that changing the API host sends the request back to the first state. add the API origin to connect-src on the page echo the page origin in Access-Control-Allow-Origin STATE 0 CSP refuses to open the connection STATE 1 request sent, the read is still blocked STATE 2 both gates satisfied JS reads the body pointing the app at a new API host resets both gates and returns the request to STATE 0 One edit per state: a second error after the first fix means the first fix worked

Verification

Where the Two Gates Diverge

Once you have both gates open for the simple case, the interesting failures are the ones where only one of them applies.

Redirects. CSP re-evaluates connect-src against every URL in a redirect chain, not just the one you typed into fetch(). If https://api.example.com/v1/data answers 302 with a Location of https://cdn-api.example.net/v1/data, the second host must also be listed or the console reports a refusal naming a URL your source code never mentions. CORS treats the same chain differently: when a cross-origin redirect moves a cors-mode request to another origin, the browser tags the request with an opaque origin, so the next hop arrives carrying Origin: null and an allowlist that only knows https://app.example.com will reject it. The redirect target must therefore be present in connect-src and be prepared for a null origin — which is usually a sign that the redirect belongs on the server side instead.

WebSocket and EventSource. connect-src covers both, but CORS does not touch a WebSocket handshake at all. The upgrade request carries an Origin header that your server is expected to validate itself; there is no preflight and no Access-Control-Allow-Origin to emit. A wss:// endpoint must be listed with its own scheme, because an https://api.example.com source expression does not match wss://api.example.com. Server-Sent Events over EventSource, by contrast, are subject to both gates, because the underlying request is an ordinary GET.

Service workers. A fetch() issued from inside a service worker is governed by the CSP of the worker script’s response, not by the CSP of the page that registered it. A page-level connect-src change therefore has no effect on requests the worker makes on its own initiative, which is a common source of “the fix works in one tab and not another” reports. Set the policy on the worker script response as well.

Report-only mode. Content-Security-Policy-Report-Only never blocks anything; it only emits a securitypolicyviolation event and an optional report. If you see a violation logged for connect-src but the request still appears in the Network panel and the data arrives, you are reading a report-only policy and the actual failure lies elsewhere — almost always on the CORS side. Chrome prints report-only violations with the phrase “would have been refused”, which is the string to grep for.

Framework defaults. Helmet’s default policy for Express sets default-src 'self' with no connect-src, so every API host has to be added explicitly. Next.js and Nuxt both let you define the header in config and in middleware, and the two silently stack — the browser enforces the intersection of the two policies, so an origin allowed in one and omitted from the other is still blocked. When a policy is delivered both as a header and as a <meta http-equiv="Content-Security-Policy"> tag, the same intersection rule applies, and the <meta> form ignores frame-ancestors, report-uri and sandbox entirely.

Security Boundary Note

Do not loosen connect-src to * or drop CSP entirely just to clear the error — that removes a defense against exfiltration by injected scripts. Add only the specific API origin you need. Likewise, do not respond to a CORS error by reflecting every origin; keep the API’s allowlist tight. The two headers are independent defenses, and weakening one to fix a symptom in the other leaves both weaker.

Common Mistakes

Issue Technical impact Mitigation
Editing API CORS headers to fix a CSP error The request never reached the API; nothing changes Fix connect-src on the page’s origin instead
Adding the API to connect-src to fix a CORS error Request was already sent; the response is still blocked Fix Access-Control-Allow-Origin on the API
Setting connect-src * to make the error go away Removes exfiltration protection for the whole page Allowlist only the specific API origin

FAQ

Can a CSP connect-src violation look like a CORS error?

They produce different console messages, but developers confuse them because both stop a cross-origin fetch. A CSP violation says the request was “Refused to connect” for violating a Content-Security-Policy directive; a CORS error says the response was “blocked by CORS policy”. CSP fires first — the request is never sent — so fixing the server’s CORS headers will not help a CSP block.

Which fires first, CSP or CORS?

CSP connect-src is evaluated before the request leaves the browser. If the destination is not allowed, the browser refuses to send the request and CORS is never reached. Only if connect-src permits the destination does the request go out and CORS then govern whether the response may be read.

Do I fix connect-src on the server or the client?

connect-src is part of the Content-Security-Policy header sent by the page’s own server (or a page meta tag). You add the API origin to that directive on the document that runs the script. CORS is fixed on the API server that receives the request — a different server.

Does connect-src apply to WebSocket connections?

Yes. WebSocket, EventSource, navigator.sendBeacon and XMLHttpRequest are all governed by connect-src, and a wss:// endpoint must be listed with its own scheme because ws: and wss: do not match an https: source expression. CORS, by contrast, does not apply to a WebSocket handshake at all — the browser sends an Origin header and the server decides whether to complete the upgrade, so there is no Access-Control-Allow-Origin to configure and no preflight.

Why is CSP still blocking a URL that is already in my connect-src list?

The three usual causes are a source expression that carries a path, a redirect, and a scheme mismatch. A source with a path segment matches by path prefix, so https://api.example.com/v1 will not match /v2. A redirect to a host that is not itself listed is refused even though the first hop was allowed. And an https: source never matches a ws:, wss: or blob: URL. Check the directive the console names — if it says default-src, your policy has no connect-src at all and is falling back, as described in how browsers evaluate the same-origin policy.