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: OPTIONS → 204 (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 string. https://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 fixed. OPTIONS 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)