DEV Community

Cover image for Fix CORS before you blame the SDK
Amorizz
Amorizz

Posted on

Fix CORS before you blame the SDK

TL;DR

  • The bug is usually the preflight (OPTIONS), not your fetch call or the client library.
  • Access-Control-Allow-Origin: * and credentials: 'include' never work together.
  • Echo an exact origin from an allowlist — not a wildcard — when the SPA and API sit on different hosts.

Your SPA lives on https://app.example.com. Your API lives on https://api.example.com. You open DevTools, hit a button, and get a red CORS error. The first instinct is to blame axios, the SDK, or “browser fetch being weird.”

Pause. Cross-origin requests that are not simple GETs almost always send an OPTIONS preflight first. If that preflight fails, the real request never leaves the browser — and nothing you change in the client will fix a missing or wrong response header on the API.

This post is a portable SPA→API CORS recipe: minimal repro, Express and Caddy configs that engineers actually paste, the credentials gotcha, and a failure checklist. No product tour. Just headers that make the browser happy.

Why “but curl works” lies

Curl never sends a CORS preflight. The browser does — and it can block the real request before your route handler runs.

From a terminal, curl -X POST https://api.example.com/v1/items -H 'Content-Type: application/json' -d '{}' talks HTTP like any other client. There is no same-origin policy. There is no Origin header the browser injects. There is no automatic OPTIONS probe.

In Chrome or Firefox, a cross-origin POST with Content-Type: application/json is a non-simple request. The browser first asks:

OPTIONS /v1/items HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type
Enter fullscreen mode Exit fullscreen mode

If the API does not answer with the right Access-Control-* headers (or does not handle OPTIONS at all), DevTools shows a CORS failure. Your Node route for POST /v1/items may be perfect. You never reached it.

Mental model: curl proves the route and auth work. The browser proves the CORS policy works. Fix both.

Minimal browser repro

Reproduce with one fetch from https://app.example.com to https://api.example.com, then watch Network for a failed OPTIONS (or a successful POST with missing CORS headers on the response).

Serve a tiny page from the SPA origin (static file, Vite, whatever — origin must be https://app.example.com):

<!DOCTYPE html>
<html>
  <body>
    <button id="go">Call API</button>
    <pre id="out"></pre>
    <script>
      document.getElementById("go").onclick = async () => {
        const out = document.getElementById("out");
        try {
          const res = await fetch("https://api.example.com/v1/items", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ name: "cors-check" }),
            // credentials: "include", // turn on only when you need cookies
          });
          out.textContent = `status ${res.status}\n` + (await res.text());
        } catch (err) {
          out.textContent = String(err);
        }
      };
    </script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Expected when CORS is broken

  • Console: something like TypeError: Failed to fetch (Chrome) or a CORS policy message naming Access-Control-Allow-Origin.
  • Network: an OPTIONS to /v1/items with status (failed), 403, or 404 — or a POST that returns 200 in Network but still throws in JS because response headers omit CORS allowances.
  • You do not see a normal JSON body in the page until both preflight and response headers are correct.

Expected when CORS is fixed

  • Network: OPTIONS204 (or 200) with Access-Control-Allow-Origin: https://app.example.com (exact match).
  • Then POST → your real status (200 / 201 / 401…); JS reads the body without a CORS TypeError.

Swap hostnames for your staging domains and keep the same shape.

What the API must send

For a browser SPA on another origin, the API must answer preflight and real responses with an allowlisted Access-Control-Allow-Origin, plus methods and headers the SPA actually uses — and Allow-Credentials only if cookies (or other credentialed mode) are in play.

Minimum response headers for the repro above (no cookies):

Header Typical value
Access-Control-Allow-Origin https://app.example.com (from allowlist — not *)
Access-Control-Allow-Methods GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers Content-Type, Authorization (mirror what the SPA sends)
Access-Control-Allow-Credentials true only if you use credentials: 'include'
Access-Control-Max-Age optional; e.g. 600 to cache preflight

Echo the request Origin when it is in the allowlist. Do not invent a second origin string.

Express (allowlist + optional credentials)

// npm i cors@2
import express from "express";
import cors from "cors";

const app = express();
app.use(express.json());

const allowlist = new Set([
  "https://app.example.com",
  "http://localhost:5173", // local Vite; drop in prod if you prefer
]);

app.use(
  cors({
    origin(origin, cb) {
      // non-browser / same-origin tools may omit Origin
      if (!origin || allowlist.has(origin)) return cb(null, true);
      return cb(new Error(`Origin ${origin} not allowlisted`));
    },
    methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
    allowedHeaders: ["Content-Type", "Authorization"],
    credentials: false, // set true only when cookies / credentialed fetch
    maxAge: 600,
  })
);

app.post("/v1/items", (req, res) => {
  res.status(201).json({ ok: true, item: req.body });
});

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

Manual OPTIONS (if you prefer not to use cors):

app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (origin && allowlist.has(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Vary", "Origin");
    res.setHeader(
      "Access-Control-Allow-Methods",
      "GET,POST,PUT,PATCH,DELETE,OPTIONS"
    );
    res.setHeader(
      "Access-Control-Allow-Headers",
      "Content-Type, Authorization"
    );
    // res.setHeader("Access-Control-Allow-Credentials", "true"); // only if needed
  }
  if (req.method === "OPTIONS") return res.sendStatus(204);
  next();
});
Enter fullscreen mode Exit fullscreen mode

Expected: with the SPA on an allowlisted origin, DevTools shows OPTIONS 204 and Access-Control-Allow-Origin equal to that origin. A non-allowlisted origin stays blocked — that is the point of the list.

Caddy (allowlist + OPTIONS)

Put CORS on the API site block (or in front of a reverse proxy). Match an exact allowlisted Origin, then echo that value — do not reflect arbitrary Origins.

api.example.com {
    # Exact Origin allowlist (values on one line are OR'd)
    @allowed header Origin https://app.example.com
    # Local Vite — OR localhost onto the same matcher:
    # @allowed header Origin https://app.example.com http://localhost:5173

    @preflight method OPTIONS

    handle @preflight {
        header @allowed {
            Access-Control-Allow-Origin "{http.request.header.Origin}"
            Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS"
            Access-Control-Allow-Headers "Content-Type, Authorization"
            Access-Control-Max-Age "600"
            Vary Origin
            # Access-Control-Allow-Credentials "true"  # only if cookies
        }
        respond 204
    }

    handle {
        header @allowed {
            Access-Control-Allow-Origin "{http.request.header.Origin}"
            Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS"
            Access-Control-Allow-Headers "Content-Type, Authorization"
            Vary Origin
        }
        reverse_proxy 127.0.0.1:3000
    }
}
Enter fullscreen mode Exit fullscreen mode

Warning: reflecting any Origin (no allowlist matcher) is insecure once credentials are on — any site can trigger credentialed requests against your API. Keep the @allowed matcher tight.

Expected: curl -i -X OPTIONS https://api.example.com/v1/items -H 'Origin: https://app.example.com' -H 'Access-Control-Request-Method: POST' returns 204 with Access-Control-Allow-Origin: https://app.example.com. A non-allowlisted Origin gets no CORS allowance. The same allowlisted origin in the browser repro then succeeds.

Credentials gotcha

If the SPA sends cookies (or credentials: 'include'), the API must return Access-Control-Allow-Credentials: true and an exact Access-Control-Allow-Origin — never *.

Common own-goal:

// SPA
fetch(url, { credentials: "include", method: "POST", ... });

// API (broken)
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Credentials", "true");
Enter fullscreen mode Exit fullscreen mode

Browsers reject that combination. Spec rule: credentialed responses cannot use *. You must echo the precise requesting origin (https://app.example.com), and that origin must be on your allowlist.

Also check:

  • Cookie SameSite / Secure / domain — CORS success does not fix a cookie the browser refuses to store or send.
  • Authorization header alone does not require credentials: 'include'. Prefer bearer tokens in headers when you can; turn credentials on only for cookie sessions.

Confirm steps + failure checklist

Confirm with DevTools Network: green OPTIONS, exact Allow-Origin, then a readable POST body — then walk the checklist if anything is still red.

Confirm

  1. Hard-refresh the SPA; clear “Disable cache” oddities if you were testing aggressively.
  2. Filter Network by OPTIONS — status 2xx/204, headers match allowlist origin.
  3. Inspect the following POST/GET — same Allow-Origin on the response (preflight alone is not enough for reading the body).
  4. Console has no CORS TypeError; your pre / UI shows the JSON (or a real app error like 401 — that is progress).
  5. Repeat from a second origin that is not allowlisted — it must still fail.

Failure checklist

  1. Missing OPTIONS route — proxy or framework returns 404/405 on preflight; add middleware or Caddy handle @preflight.
  2. Allow-Origin: * with credentials — browser blocks; switch to exact origin echo.
  3. Wrong origin stringhttps://app.example.com vs https://www.app.example.com vs trailing slash; allowlist the exact Origin header value.
  4. Proxy stripping headers — CDN / nginx / Caddy upstream drops Access-Control-*; set headers at the edge that the browser sees.
  5. Allow-Headers incomplete — SPA sends Authorization or a custom header not listed; preflight fails even when Origin is fine.
  6. Only preflight fixedOPTIONS OK but actual POST response omits CORS headers; apply the same policy to all methods.
  7. HTTP vs HTTPS mismatch — SPA on https://, API redirect or mixed content; fix TLS before debugging CORS.
  8. Cached bad preflight — old Max-Age remembered; try a private window or wait out cache while iterating.

Same OPTIONS wall on another host

Any browser SDK that POSTs from your SPA origin to a different host hits the same preflight rules — allowlist the SPA origin and set credentials correctly on that host too. If you need a place to stand an ingest host up, the self-hosting install notes cover the basics.

Discussion

What was your dumbest CORS own-goal — * with credentials, missing OPTIONS route, or a proxy stripping headers?

Top comments (0)