Guide

CORS explained: cross-origin requests, preflight, and fixes

Your React app on https://app.example.com calls an API at https://api.example.com. Postman works. curl works. The browser console shows a red error about CORS and your fetch returns nothing. That mismatch is one of the most common surprises in modern web development — and it is working as designed. Cross-Origin Resource Sharing (CORS) is the browser's contract for when JavaScript on one origin may read a response from another. This guide explains the same-origin policy, simple vs preflight requests, the headers that matter, and how to fix blocked calls without weakening security.

Origins and the same-origin policy

An origin is the combination of scheme, host, and port: https://solana.garden:443 is one origin; http://solana.garden is another (different scheme); https://api.solana.garden is another (different host).

Browsers enforce the same-origin policy for scripts: code loaded from origin A cannot freely read responses from origin B. Without that rule, any website you visit could silently call your bank's API using cookies you already have and exfiltrate the JSON.

CORS is not a firewall and not enforced by curl or server-side code. It is a browser mechanism. The server still receives cross-origin requests; CORS only controls whether client-side JavaScript is allowed to see the response body and headers.

How CORS allows cross-origin reads

When a cross-origin request completes, the browser compares the response headers against the requesting page's origin. If the server explicitly permits that origin, the browser exposes the response to your script. Otherwise the network request may succeed (HTTP 200) but JavaScript gets a generic network error — the classic "CORS blocked" confusion.

The most important response headers:

  • Access-Control-Allow-Origin — which origins may read the response. A specific origin (https://app.example.com) is safest. The wildcard * works only for requests without credentials (cookies or Authorization headers).
  • Access-Control-Allow-Methods — permitted HTTP verbs for preflight (GET, POST, PUT, DELETE, etc.).
  • Access-Control-Allow-Headers — custom request headers the client may send (for example Content-Type, Authorization, X-Request-Id).
  • Access-Control-Allow-Credentials — set to true when the client uses credentials: 'include' and you need cookies. Requires a specific origin, never *.
  • Access-Control-Expose-Headers — which non-simple response headers JavaScript may read (defaults are minimal: Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma).

Configure these on the API server (or on a reverse proxy in front of it). Changing frontend code cannot bypass CORS — that would defeat the purpose.

Simple requests vs preflight

Not every cross-origin call triggers a preflight. A simple request uses GET, HEAD, or POST with a Content-Type of application/x-www-form-urlencoded, multipart/form-data, or text/plain, and only "simple" headers. The browser sends one request; the server answers with CORS headers on that same response.

A preflight happens when the request is "non-simple" — JSON bodies (Content-Type: application/json), custom headers like Authorization, or methods like PUT and PATCH. The browser first sends an OPTIONS request with Access-Control-Request-Method and Access-Control-Request-Headers. The server must respond with matching Access-Control-Allow-* values and typically HTTP 204. Only if preflight succeeds does the real request go out.

Preflight adds one round trip. For high-frequency APIs, keep simple GETs simple where possible, or cache preflight results via Access-Control-Max-Age (often 600–86400 seconds).

When preflight is skipped entirely

Cross-origin <img>, <script>, and <link> tags load resources without CORS checks for display — but you cannot read pixel data from a cross-origin image in a canvas without crossorigin and proper CORS headers (otherwise the canvas is "tainted"). WebSockets use a different handshake (see our WebSockets guide) but still respect origin checks at connection time.

Credentials: cookies and Authorization headers

By default, fetch does not send cookies on cross-origin calls. To include them, set credentials: 'include' (XHR: withCredentials = true). The server must reply with Access-Control-Allow-Credentials: true and echo the exact requesting origin in Access-Control-Allow-Origin — not *.

Bearer tokens in an Authorization header are not cookies, but they still make the request non-simple, so preflight applies. Many OAuth flows redirect back to your origin with a code; subsequent API calls from the SPA attach the access token and depend on correct CORS configuration on the resource server.

A common mistake: enabling credentials on the client while the API returns Access-Control-Allow-Origin: *. Browsers reject that combination every time.

CORS is not CSRF protection

CORS stops JavaScript on site A from reading responses from site B. It does not stop site A from sending requests to site B. A classic HTML form POST or an image beacon can still hit your API; the browser just will not let attacker's script inspect the reply.

Protect state-changing endpoints with CSRF tokens, SameSite cookies, or proof-of-possession for APIs — do not treat permissive CORS as authentication. For machine-to-machine traffic, use server-side calls or an API gateway; CORS only applies to browser contexts.

Debugging CORS failures

The console message usually names the blocked origin and the missing header. Work through this checklist:

  1. Confirm it is CORS. If curl returns 200 with a body but fetch fails, it is almost certainly CORS, not "the API is down."
  2. Check preflight. In DevTools Network, filter OPTIONS. If preflight returns 404 or lacks Allow headers, fix the server before debugging the POST.
  3. Match origins exactly. https://app.com and https://www.app.com are different. Trailing slashes in Allow-Origin are invalid.
  4. Expose headers you need. If your client reads X-RateLimit-Remaining, add it to Access-Control-Expose-Headers.
  5. Redirects strip CORS. If http:// redirects to https://, preflight may fail unless the redirect response also carries CORS headers (prefer HTTPS everywhere).

During local development, a Vite or webpack dev proxy forwards /api to the backend so the browser sees same-origin traffic — a legitimate pattern. Disabling web security in Chrome is not; it hides the problem you will hit in production.

CORS in crypto apps and Solana dApps

Wallet adapters and RPC calls illustrate two different worlds. Wallet extensions inject providers into your page — that is same-origin from the page's perspective. Public Solana RPC URLs, however, are cross-origin from your dApp. Many providers allow browser access with permissive CORS; some block it and expect you to proxy through your backend — partly for API key secrecy, partly for rate control.

If getBalance works in a Node script but fails in the browser, check whether the RPC endpoint allows your site's origin. A thin server-side proxy also lets you add caching, backoff on 429 responses, and request coalescing — patterns that matter when many users share one API key.

Solana Pay and deep links do not involve CORS for the payment itself (the wallet app is a separate application), but your site still needs CORS on any REST endpoint that verifies payments or serves game state to the frontend.

Secure configuration patterns

  • Allowlist origins — maintain an explicit list per environment (production, staging). Reject unknown origins rather than reflecting arbitrary Origin headers unless you understand the risks.
  • Separate public and private APIs. Read-only public JSON might use *; authenticated routes get strict origins and credentials support.
  • Terminate CORS at the edge. nginx, Cloudflare, and API gateways can inject consistent headers so application code stays simple.
  • Do not use CORS to hide secrets. Any key embedded in frontend JavaScript is public. Proxy sensitive calls server-side.
Quick reference: common CORS scenarios
Scenario Preflight? Typical fix
GET public JSON API Often no Access-Control-Allow-Origin on GET response
POST with JSON body Yes Handle OPTIONS + Allow-Methods/Headers
Cookie session API Yes Specific origin + Allow-Credentials: true
Third-party RPC from browser POST preflight Provider CORS or backend proxy

Related reading