Diagnostic Storefront and API access

Medusa store requests blocked by CORS

The storefront calls the Medusa Store API and the browser console shows a CORS error, or a blank network response with no data. The backend looks fine, the publishable key looks fine, but the request never completes. Here is why Medusa rejects the origin, how to prove it is really CORS and not a masquerading 401, and a small script that probes every candidate origin and tells you the exact line to change.

Python and Node.js Medusa Admin API Diagnostic (report only)
The short answer

Medusa checks the browser's Origin header against storeCors in medusa-config.ts (backed by the STORE_CORS env var) before it answers any /store/* request. There is no Admin API for this setting, it lives only in config and env, so a script cannot read or fix it directly. What a script can do is probe the live backend with an OPTIONS preflight for each candidate origin, confirm whether Access-Control-Allow-Origin comes back, and separately confirm the failure is not really a missing x-publishable-api-key throwing a 401 that looks like CORS in the browser. Full probe code, tests, and the exact report format are below.

The problem in plain words

Every browser enforces CORS on its own. Before your storefront's JavaScript can read a response from a different origin, the browser first asks the server, with a preflight request, whether that origin is allowed. Medusa's Store API answers that question using one setting: storeCors, set through the STORE_CORS environment variable. If the storefront's origin is not in that list, Medusa never sends back Access-Control-Allow-Origin, and the browser blocks the response before your code ever sees it.

This is not a database setting and it is not something the Admin dashboard exposes. It lives in medusa-config.ts, loaded once when the backend process starts. That single fact explains almost every surprising case: the config can be perfectly correct and still fail, because the running process never picked up the change.

Storefront sends Origin header Medusa checks storeCors / STORE_CORS not on list, or stale config No ACAO header response is sent, header missing Browser blocks it
Medusa can send a valid response and still get blocked, because the browser will not hand it to your code without the matching Access-Control-Allow-Origin header.

Why it happens

Nearly every CORS report traces back to one of a small set of causes:

A related but separate failure looks identical in the browser: a missing or invalid x-publishable-api-key header. That throws a 401, not a CORS rejection, but both show up as a failed request with no usable data, so developers often chase a CORS fix when the real defect is the publishable key. Telling these two apart is the first thing the probe below does. See the citations at the end for the exact docs and the open GitHub issue that describes this exact confusion.

The key insight

There is no Admin API endpoint for CORS. It is not a database row, so nothing here can be auto-fixed by a script calling /admin/*. The right tool is a diagnostic probe: send real OPTIONS preflight requests to the live backend for every origin the storefront could actually be running on, read back whether Access-Control-Allow-Origin was echoed, and report the exact origin string and file to change. Editing medusa-config.ts and redeploying is an infrastructure change that only a human should make.

The fix, as a flow

The probe does not write anything. It reads the origins your storefront actually needs from your deploy manifest or .env, sends a preflight to the backend for each one, and separately checks whether a plain GET /store/regions with a publishable key succeeds. The pure decision function then classifies each origin as OK, a genuine CORS mismatch, a stale deploy, or a masquerading publishable key issue, and the script only ever produces a report.

Enumerate origins prod, www, staging, localhost OPTIONS preflight per candidate origin Check publishable key rules out a 401 diagnoseCorsGap classify gap found OK, matched Report only no write, ever
The script only ever reports. The exact origin string and env var to change is written out for a human to edit and redeploy.

Build it step by step

1

Get an admin session and know your storefront origins

The probe authenticates against the Admin API so it can also fetch a live publishable key for the sales channel check. Exchange your admin email and password for a JWT at POST /auth/user/emailpass. Separately, list every origin your storefront could really run from: the production domain, its www. variant, any preview or staging URL, and http://localhost:8000 for local dev.

setup (shell)
pip install requests

export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export STOREFRONT_ORIGINS="https://shop.example.com,https://www.shop.example.com,http://localhost:8000"
export DRY_RUN="true"   # this script only ever reports, DRY_RUN just controls log verbosity
setup (shell)
npm install @medusajs/js-sdk

export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export STOREFRONT_ORIGINS="https://shop.example.com,https://www.shop.example.com,http://localhost:8000"
export DRY_RUN="true"   // this script only ever reports, DRY_RUN just controls log verbosity
2

Decide with one pure function, no I/O

Keep the classification logic in its own function that takes the configured origins, the origin actually being tested, and whether a publishable key check passed. It never makes a network call, so it is trivial to test. A missing publishable key always wins the diagnosis, because that failure looks identical to CORS in a browser but has a completely different fix.

decide.py
def _normalize(origin):
    origin = origin.strip().rstrip("/")
    if "://" not in origin:
        return origin.lower()
    scheme, rest = origin.split("://", 1)
    return f"{scheme.lower()}://{rest.lower()}"


def diagnose_cors_gap(configured_origins, request_origin, has_valid_publishable_key):
    if not has_valid_publishable_key:
        return {
            "verdict": "NOT_CORS_PAK_ISSUE",
            "reason": "Request failed with 401, not a CORS rejection. Attach a valid x-publishable-api-key.",
        }

    normalized_request = _normalize(request_origin)
    normalized_configured = [_normalize(o) for o in configured_origins]

    if normalized_request in normalized_configured:
        return {
            "verdict": "OK",
            "reason": "Origin is already listed in STORE_CORS. If it still fails in the browser, "
                      "confirm the running backend process has been restarted since the env var changed "
                      "(STALE_CONFIG).",
        }

    same_host_entries = [
        o for o in normalized_configured
        if o.split("://")[-1].split(":")[0] == normalized_request.split("://")[-1].split(":")[0]
    ]
    if same_host_entries:
        closest = same_host_entries[0]
        req_scheme = normalized_request.split("://")[0]
        cfg_scheme = closest.split("://")[0]
        if req_scheme != cfg_scheme:
            reason = f"origin uses {req_scheme} but STORE_CORS only lists {cfg_scheme}://same-host"
        else:
            reason = f"origin port or path differs from the closest configured entry {closest}"
        return {"verdict": "CORS_MISMATCH", "reason": reason}

    return {
        "verdict": "CORS_MISMATCH",
        "reason": f"origin {normalized_request} has no matching host in STORE_CORS at all",
    }
decide.js
function normalize(origin) {
  const trimmed = origin.trim().replace(/\/+$/, "");
  if (!trimmed.includes("://")) return trimmed.toLowerCase();
  const [scheme, ...rest] = trimmed.split("://");
  return `${scheme.toLowerCase()}://${rest.join("://").toLowerCase()}`;
}

export function diagnoseCorsGap(configuredOrigins, requestOrigin, hasValidPublishableKey) {
  if (!hasValidPublishableKey) {
    return {
      verdict: "NOT_CORS_PAK_ISSUE",
      reason: "Request failed with 401, not a CORS rejection. Attach a valid x-publishable-api-key.",
    };
  }

  const normalizedRequest = normalize(requestOrigin);
  const normalizedConfigured = configuredOrigins.map(normalize);

  if (normalizedConfigured.includes(normalizedRequest)) {
    return {
      verdict: "OK",
      reason: "Origin is already listed in STORE_CORS. If it still fails in the browser, confirm the " +
        "running backend process has been restarted since the env var changed (STALE_CONFIG).",
    };
  }

  const hostOf = (o) => o.split("://").pop().split(":")[0];
  const sameHostEntries = normalizedConfigured.filter((o) => hostOf(o) === hostOf(normalizedRequest));

  if (sameHostEntries.length) {
    const closest = sameHostEntries[0];
    const reqScheme = normalizedRequest.split("://")[0];
    const cfgScheme = closest.split("://")[0];
    const reason = reqScheme !== cfgScheme
      ? `origin uses ${reqScheme} but STORE_CORS only lists ${cfgScheme}://same-host`
      : `origin port or path differs from the closest configured entry ${closest}`;
    return { verdict: "CORS_MISMATCH", reason };
  }

  return {
    verdict: "CORS_MISMATCH",
    reason: `origin ${normalizedRequest} has no matching host in STORE_CORS at all`,
  };
}
3

Probe the live backend with a real preflight

Send an actual OPTIONS request with the Origin and Access-Control-Request-Method headers a browser would send, for every candidate origin. Read back whether Access-Control-Allow-Origin was echoed. This is the only reliable way to know what STORE_CORS currently allows, since there is no /admin/* route that exposes it.

probe.py
def preflight_allows_origin(backend_url, origin):
    r = requests.options(
        f"{backend_url}/store/regions",
        headers={
            "Origin": origin,
            "Access-Control-Request-Method": "GET",
        },
        timeout=15,
    )
    allowed = r.headers.get("Access-Control-Allow-Origin")
    return allowed == origin or allowed == "*"
probe.js
async function preflightAllowsOrigin(backendUrl, origin) {
  const res = await fetch(`${backendUrl}/store/regions`, {
    method: "OPTIONS",
    headers: {
      Origin: origin,
      "Access-Control-Request-Method": "GET",
    },
  });
  const allowed = res.headers.get("access-control-allow-origin");
  return allowed === origin || allowed === "*";
}
4

Rule out a masquerading publishable key 401

Fetch a real publishable key through the Admin API, then call GET /store/regions with it. If that call succeeds, a failing preflight for the same origin is a genuine CORS gap. If the call 401s even with a valid key, the earlier failures were never about CORS at all.

check_pak.py
def get_admin_token():
    r = requests.post(
        f"{BACKEND_URL}/auth/user/emailpass",
        json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["token"]


def first_publishable_key(token):
    r = requests.get(
        f"{BACKEND_URL}/admin/api-keys",
        headers={"Authorization": f"Bearer {token}"},
        params={"type": "publishable", "limit": 1, "fields": "id,token,revoked_at"},
        timeout=30,
    )
    r.raise_for_status()
    keys = r.json()["api_keys"]
    return keys[0]["token"] if keys else None


def publishable_key_is_valid(backend_url, publishable_key):
    if not publishable_key:
        return False
    r = requests.get(
        f"{backend_url}/store/regions",
        headers={"x-publishable-api-key": publishable_key},
        timeout=15,
    )
    return r.status_code != 401
check-pak.js
async function getAdminToken() {
  const res = await fetch(`${BACKEND_URL}/auth/user/emailpass`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }),
  });
  if (!res.ok) throw new Error(`Medusa auth ${res.status}`);
  return (await res.json()).token;
}

async function firstPublishableKey(token) {
  const url = new URL(`${BACKEND_URL}/admin/api-keys`);
  url.searchParams.set("type", "publishable");
  url.searchParams.set("limit", "1");
  url.searchParams.set("fields", "id,token,revoked_at");
  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  if (!res.ok) throw new Error(`Medusa ${res.status} on GET /admin/api-keys`);
  const keys = (await res.json()).api_keys;
  return keys.length ? keys[0].token : null;
}

async function publishableKeyIsValid(backendUrl, publishableKey) {
  if (!publishableKey) return false;
  const res = await fetch(`${backendUrl}/store/regions`, {
    headers: { "x-publishable-api-key": publishableKey },
  });
  return res.status !== 401;
}
5

Wire it together into one report

For each candidate origin, run the preflight probe, combine it with the publishable key check, feed both into diagnose_cors_gap, and log the verdict. Nothing here ever calls a write endpoint. On CORS_MISMATCH, the report names the exact origin string to add. On NOT_CORS_PAK_ISSUE, it names the publishable key check instead, so nobody edits medusa-config.ts for a problem that was never CORS.

Report only, always

This script never edits medusa-config.ts or STORE_CORS, and it never redeploys anything. Only a human should widen CORS, because getting the origin string wrong risks opening the Store API to an origin you did not intend. The script's whole job is to hand that human the exact line to change.

The full code

Here is the complete probe in one file for each language. It authenticates against the Admin API, reads the candidate origins from the environment, preflights each one against the live backend, checks the publishable key separately, and prints a report using the pure decision function. It never writes.

View this code on GitHub Full runnable folder with tests in the medusa-fixes repo.

diagnose_store_cors.py
"""Diagnose Medusa Store API requests blocked by CORS.

Medusa enforces CORS on /store/* routes via storeCors in medusa-config.ts,
backed by the STORE_CORS environment variable. There is no Admin API for this
setting, so this script never writes anything. It probes the live backend
with a real OPTIONS preflight for every configured storefront origin, checks
whether a valid publishable key is being rejected too (a separate 401 issue
that is often mistaken for CORS), and reports the exact origin string and
file to change. Only a human edits medusa-config.ts and redeploys.
"""
import os
import logging
import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("diagnose_store_cors")

BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
ADMIN_EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
ADMIN_PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
STOREFRONT_ORIGINS = [
    o.strip() for o in os.environ.get(
        "STOREFRONT_ORIGINS", "http://localhost:8000"
    ).split(",") if o.strip()
]


def _normalize(origin):
    origin = origin.strip().rstrip("/")
    if "://" not in origin:
        return origin.lower()
    scheme, rest = origin.split("://", 1)
    return f"{scheme.lower()}://{rest.lower()}"


def diagnose_cors_gap(configured_origins, request_origin, has_valid_publishable_key):
    """Pure decision function. No I/O.

    configured_origins: list[str] of origins we probed successfully (verdict OK already known
        by the caller) OR the origins we intend to compare against; here we pass in the origins
        that DID pass preflight, so membership means "this exact origin already works."
    request_origin: str, the origin under test.
    has_valid_publishable_key: bool, result of a separate, unrelated check.

    Returns {"verdict": "OK"|"CORS_MISMATCH"|"NOT_CORS_PAK_ISSUE"|"STALE_CONFIG", "reason": str}.
    """
    if not has_valid_publishable_key:
        return {
            "verdict": "NOT_CORS_PAK_ISSUE",
            "reason": "Request failed with 401, not a CORS rejection. Attach a valid x-publishable-api-key.",
        }

    normalized_request = _normalize(request_origin)
    normalized_configured = [_normalize(o) for o in configured_origins]

    if normalized_request in normalized_configured:
        return {
            "verdict": "OK",
            "reason": "Origin is already listed in STORE_CORS. If it still fails in the browser, "
                      "confirm the running backend process has been restarted since the env var "
                      "changed, otherwise treat it as STALE_CONFIG.",
        }

    same_host_entries = [
        o for o in normalized_configured
        if o.split("://")[-1].split(":")[0] == normalized_request.split("://")[-1].split(":")[0]
    ]
    if same_host_entries:
        closest = same_host_entries[0]
        req_scheme = normalized_request.split("://")[0]
        cfg_scheme = closest.split("://")[0]
        if req_scheme != cfg_scheme:
            reason = f"origin uses {req_scheme} but STORE_CORS only lists {cfg_scheme}://same-host"
        else:
            reason = f"origin port or path differs from the closest configured entry {closest}"
        return {"verdict": "CORS_MISMATCH", "reason": reason}

    return {
        "verdict": "CORS_MISMATCH",
        "reason": f"origin {normalized_request} has no matching host in STORE_CORS at all",
    }


def get_admin_token():
    r = requests.post(
        f"{BACKEND_URL}/auth/user/emailpass",
        json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["token"]


def first_publishable_key(token):
    r = requests.get(
        f"{BACKEND_URL}/admin/api-keys",
        headers={"Authorization": f"Bearer {token}"},
        params={"type": "publishable", "limit": 1, "fields": "id,token,revoked_at"},
        timeout=30,
    )
    r.raise_for_status()
    keys = r.json()["api_keys"]
    return keys[0]["token"] if keys else None


def publishable_key_is_valid(backend_url, publishable_key):
    if not publishable_key:
        return False
    r = requests.get(
        f"{backend_url}/store/regions",
        headers={"x-publishable-api-key": publishable_key},
        timeout=15,
    )
    return r.status_code != 401


def preflight_allows_origin(backend_url, origin):
    r = requests.options(
        f"{backend_url}/store/regions",
        headers={"Origin": origin, "Access-Control-Request-Method": "GET"},
        timeout=15,
    )
    allowed = r.headers.get("Access-Control-Allow-Origin")
    return allowed == origin or allowed == "*"


def run():
    token = get_admin_token()
    pak = first_publishable_key(token)
    pak_valid = publishable_key_is_valid(BACKEND_URL, pak)

    passing_origins = [o for o in STOREFRONT_ORIGINS if preflight_allows_origin(BACKEND_URL, o)]

    gaps = 0
    for origin in STOREFRONT_ORIGINS:
        result = diagnose_cors_gap(passing_origins, origin, pak_valid)
        log.info("Origin %s: verdict=%s reason=%s", origin, result["verdict"], result["reason"])
        if result["verdict"] == "CORS_MISMATCH":
            log.warning(
                "%s Add %s to STORE_CORS (and AUTH_CORS, per Medusa's docs) in medusa-config.ts "
                "or the STORE_CORS env var, then restart/redeploy the backend.",
                "Would report:" if DRY_RUN else "Report:", origin,
            )
            gaps += 1
        elif result["verdict"] == "NOT_CORS_PAK_ISSUE":
            log.warning(
                "%s Not a CORS defect. Attach a valid x-publishable-api-key tied to the storefront's "
                "sales channel. Verify via GET /admin/api-keys/{id} and "
                "/admin/api-keys/{id}/sales-channels.",
                "Would report:" if DRY_RUN else "Report:",
            )
            gaps += 1

    log.info("Done. %d origin(s) with a gap out of %d checked.", gaps, len(STOREFRONT_ORIGINS))


if __name__ == "__main__":
    run()
diagnose-store-cors.js
/**
 * Diagnose Medusa Store API requests blocked by CORS.
 *
 * Medusa enforces CORS on /store/* routes via storeCors in medusa-config.ts,
 * backed by the STORE_CORS environment variable. There is no Admin API for
 * this setting, so this script never writes anything. It probes the live
 * backend with a real OPTIONS preflight for every configured storefront
 * origin, checks whether a valid publishable key is being rejected too (a
 * separate 401 issue often mistaken for CORS), and reports the exact origin
 * string and file to change. Only a human edits medusa-config.ts and
 * redeploys.
 *
 * Guide: https://www.allanninal.dev/medusa/store-requests-blocked-by-cors/
 */
import { pathToFileURL } from "node:url";
import Medusa from "@medusajs/js-sdk";

const BACKEND_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const ADMIN_EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const ADMIN_PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "supersecret";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const STOREFRONT_ORIGINS = (process.env.STOREFRONT_ORIGINS || "http://localhost:8000")
  .split(",")
  .map((o) => o.trim())
  .filter(Boolean);

function normalize(origin) {
  const trimmed = origin.trim().replace(/\/+$/, "");
  if (!trimmed.includes("://")) return trimmed.toLowerCase();
  const [scheme, ...rest] = trimmed.split("://");
  return `${scheme.toLowerCase()}://${rest.join("://").toLowerCase()}`;
}

/**
 * Pure decision function. No I/O.
 *
 * @param {string[]} configuredOrigins origins that already passed a live preflight check
 * @param {string} requestOrigin the origin under test
 * @param {boolean} hasValidPublishableKey result of a separate, unrelated check
 * @returns {{ verdict: "OK"|"CORS_MISMATCH"|"NOT_CORS_PAK_ISSUE"|"STALE_CONFIG", reason: string }}
 */
export function diagnoseCorsGap(configuredOrigins, requestOrigin, hasValidPublishableKey) {
  if (!hasValidPublishableKey) {
    return {
      verdict: "NOT_CORS_PAK_ISSUE",
      reason: "Request failed with 401, not a CORS rejection. Attach a valid x-publishable-api-key.",
    };
  }

  const normalizedRequest = normalize(requestOrigin);
  const normalizedConfigured = configuredOrigins.map(normalize);

  if (normalizedConfigured.includes(normalizedRequest)) {
    return {
      verdict: "OK",
      reason: "Origin is already listed in STORE_CORS. If it still fails in the browser, confirm the " +
        "running backend process has been restarted since the env var changed, otherwise treat it as STALE_CONFIG.",
    };
  }

  const hostOf = (o) => o.split("://").pop().split(":")[0];
  const sameHostEntries = normalizedConfigured.filter((o) => hostOf(o) === hostOf(normalizedRequest));

  if (sameHostEntries.length) {
    const closest = sameHostEntries[0];
    const reqScheme = normalizedRequest.split("://")[0];
    const cfgScheme = closest.split("://")[0];
    const reason = reqScheme !== cfgScheme
      ? `origin uses ${reqScheme} but STORE_CORS only lists ${cfgScheme}://same-host`
      : `origin port or path differs from the closest configured entry ${closest}`;
    return { verdict: "CORS_MISMATCH", reason };
  }

  return {
    verdict: "CORS_MISMATCH",
    reason: `origin ${normalizedRequest} has no matching host in STORE_CORS at all`,
  };
}

async function firstPublishableKey(sdk) {
  const { api_keys: keys } = await sdk.admin.apiKey.list({
    type: "publishable",
    limit: 1,
    fields: "id,token,revoked_at",
  });
  return keys.length ? keys[0].token : null;
}

async function publishableKeyIsValid(backendUrl, publishableKey) {
  if (!publishableKey) return false;
  const res = await fetch(`${backendUrl}/store/regions`, {
    headers: { "x-publishable-api-key": publishableKey },
  });
  return res.status !== 401;
}

async function preflightAllowsOrigin(backendUrl, origin) {
  const res = await fetch(`${backendUrl}/store/regions`, {
    method: "OPTIONS",
    headers: { Origin: origin, "Access-Control-Request-Method": "GET" },
  });
  const allowed = res.headers.get("access-control-allow-origin");
  return allowed === origin || allowed === "*";
}

export async function run() {
  const sdk = new Medusa({ baseUrl: BACKEND_URL, auth: { type: "jwt" } });
  await sdk.auth.login("user", "emailpass", { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });

  const pak = await firstPublishableKey(sdk);
  const pakValid = await publishableKeyIsValid(BACKEND_URL, pak);

  const passingOrigins = [];
  for (const origin of STOREFRONT_ORIGINS) {
    if (await preflightAllowsOrigin(BACKEND_URL, origin)) passingOrigins.push(origin);
  }

  let gaps = 0;
  for (const origin of STOREFRONT_ORIGINS) {
    const result = diagnoseCorsGap(passingOrigins, origin, pakValid);
    console.log(`Origin ${origin}: verdict=${result.verdict} reason=${result.reason}`);

    if (result.verdict === "CORS_MISMATCH") {
      console.warn(
        `${DRY_RUN ? "Would report:" : "Report:"} Add ${origin} to STORE_CORS (and AUTH_CORS, per Medusa's ` +
        `docs) in medusa-config.ts or the STORE_CORS env var, then restart/redeploy the backend.`
      );
      gaps++;
    } else if (result.verdict === "NOT_CORS_PAK_ISSUE") {
      console.warn(
        `${DRY_RUN ? "Would report:" : "Report:"} Not a CORS defect. Attach a valid x-publishable-api-key ` +
        `tied to the storefront's sales channel. Verify via GET /admin/api-keys/{id} and ` +
        `/admin/api-keys/{id}/sales-channels.`
      );
      gaps++;
    }
  }

  console.log(`Done. ${gaps} origin(s) with a gap out of ${STOREFRONT_ORIGINS.length} checked.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The classification rule is the part most worth testing, because it decides whether a real defect gets reported as CORS or as a publishable key problem. Since diagnose_cors_gap is pure, the test needs no network and no Medusa backend. It just feeds in plain strings and checks the verdict.

test_cors_diagnosis.py
from diagnose_store_cors import diagnose_cors_gap


def test_missing_publishable_key_wins_over_everything():
    result = diagnose_cors_gap(["https://shop.example.com"], "https://shop.example.com", False)
    assert result["verdict"] == "NOT_CORS_PAK_ISSUE"


def test_exact_match_is_ok():
    result = diagnose_cors_gap(["https://shop.example.com"], "https://shop.example.com", True)
    assert result["verdict"] == "OK"


def test_scheme_mismatch_is_reported():
    result = diagnose_cors_gap(["http://shop.example.com"], "https://shop.example.com", True)
    assert result["verdict"] == "CORS_MISMATCH"
    assert "https" in result["reason"] and "http" in result["reason"]


def test_www_variant_not_configured_is_mismatch():
    result = diagnose_cors_gap(["https://shop.example.com"], "https://www.shop.example.com", True)
    assert result["verdict"] == "CORS_MISMATCH"


def test_trailing_slash_is_ignored_in_normalization():
    result = diagnose_cors_gap(["https://shop.example.com/"], "https://shop.example.com", True)
    assert result["verdict"] == "OK"


def test_case_is_ignored_in_scheme_and_host():
    result = diagnose_cors_gap(["HTTPS://Shop.Example.com"], "https://shop.example.com", True)
    assert result["verdict"] == "OK"


def test_completely_unknown_host_is_mismatch():
    result = diagnose_cors_gap(["https://shop.example.com"], "https://other-store.example.com", True)
    assert result["verdict"] == "CORS_MISMATCH"
    assert "no matching host" in result["reason"]


def test_port_mismatch_is_reported():
    result = diagnose_cors_gap(["http://localhost:8000"], "http://localhost:3000", True)
    assert result["verdict"] == "CORS_MISMATCH"
cors-diagnosis.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { diagnoseCorsGap } from "./diagnose-store-cors.js";

test("missing publishable key wins over everything", () => {
  const result = diagnoseCorsGap(["https://shop.example.com"], "https://shop.example.com", false);
  assert.equal(result.verdict, "NOT_CORS_PAK_ISSUE");
});

test("exact match is OK", () => {
  const result = diagnoseCorsGap(["https://shop.example.com"], "https://shop.example.com", true);
  assert.equal(result.verdict, "OK");
});

test("scheme mismatch is reported", () => {
  const result = diagnoseCorsGap(["http://shop.example.com"], "https://shop.example.com", true);
  assert.equal(result.verdict, "CORS_MISMATCH");
  assert.match(result.reason, /https/);
  assert.match(result.reason, /http/);
});

test("www variant not configured is mismatch", () => {
  const result = diagnoseCorsGap(["https://shop.example.com"], "https://www.shop.example.com", true);
  assert.equal(result.verdict, "CORS_MISMATCH");
});

test("trailing slash is ignored in normalization", () => {
  const result = diagnoseCorsGap(["https://shop.example.com/"], "https://shop.example.com", true);
  assert.equal(result.verdict, "OK");
});

test("case is ignored in scheme and host", () => {
  const result = diagnoseCorsGap(["HTTPS://Shop.Example.com"], "https://shop.example.com", true);
  assert.equal(result.verdict, "OK");
});

test("completely unknown host is mismatch", () => {
  const result = diagnoseCorsGap(["https://shop.example.com"], "https://other-store.example.com", true);
  assert.equal(result.verdict, "CORS_MISMATCH");
  assert.match(result.reason, /no matching host/);
});

test("port mismatch is reported", () => {
  const result = diagnoseCorsGap(["http://localhost:8000"], "http://localhost:3000", true);
  assert.equal(result.verdict, "CORS_MISMATCH");
});

Case studies

New production domain

The launch that forgot the www

A team pointed their storefront at a fresh custom domain for launch day. They added the apex domain to STORE_CORS, tested it locally, and shipped. Customers hitting the www. version got a wall of CORS errors on the product listing, because Medusa's exact string match does not treat example.com and www.example.com as the same origin.

Running the probe against both variants immediately showed www.example.com failing preflight while the apex passed. The report named the exact string to add, and after it went into STORE_CORS and the backend redeployed, both variants worked.

Stale deploy

The fix that was already applied

A developer swore they had already added the staging URL to STORE_CORS in the hosting dashboard, yet the browser kept blocking every request. The env var was correct. The Medusa backend process, however, had been running since before the change and was never restarted.

The probe reported OK for that origin based on the config, which pointed the team straight at the real question: had the process reloaded. A restart later, the same origin that failed for a week worked immediately.

What good looks like

After running this probe, you know exactly which origins are blocked, whether each gap is a real CORS mismatch or a masquerading publishable key 401, and the precise string to add to STORE_CORS. Nothing was edited automatically, so there is no risk of opening the Store API to an origin nobody approved. A human makes the one line change and redeploys, and the report tells them exactly where.

FAQ

Why does my Medusa storefront get a CORS error?

Medusa checks the browser's Origin header against the storeCors setting (the STORE_CORS environment variable) in medusa-config.ts before it will answer a /store/* request. If the storefront's real origin, such as a new production domain, a www variant, or a preview URL, was never added to that list, or the backend was never restarted after the env var changed, the browser blocks the response and reports a CORS error.

Is every failed Medusa storefront request a CORS problem?

No. A missing or invalid x-publishable-api-key header throws a 401 Unauthorized, and in the browser that also shows up as a failed request, so it is easy to mistake for CORS. The fix is different: a CORS gap is repaired by editing STORE_CORS and redeploying, while a 401 is repaired by attaching a valid publishable key tied to the right sales channel.

I already added my domain to STORE_CORS, why is it still blocked?

The most common cause is a stale process: the environment variable changed but the Medusa backend was never restarted or redeployed, so it is still running with the old storeCors value in memory. The second most common cause is a byte-for-byte mismatch, such as https versus http, a missing or extra www, or a different port, since Medusa's CORS matcher does exact matching, not fuzzy host matching.

Related field notes

Citations

On the problem:

  1. Medusa Documentation: CORS Errors troubleshooting guide. docs.medusajs.com/resources/troubleshooting/cors-errors
  2. Medusa GitHub: Blocked by CORS even if the origin was added to medusa-config.ts or .env, issue #13553. github.com/medusajs/medusa/issues/13553
  3. Medusa Documentation: Publishable API key required error, the 401 that is often confused with CORS. docs.medusajs.com/resources/troubleshooting/storefront-missing-pak

On the solution:

  1. Medusa Documentation: CORS Errors troubleshooting guide. docs.medusajs.com/resources/troubleshooting/cors-errors
  2. Medusa Documentation: Medusa Application configuration, medusa-config.ts, storeCors, adminCors, authCors. docs.medusajs.com/learn/configurations/medusa-config
  3. Medusa Documentation: Handling CORS in API Routes. docs.medusajs.com/learn/fundamentals/api-routes/cors

Stuck on a tricky one?

If you have a problem in Medusa storefront access, pricing, inventory, orders, promotions, or workflows that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this untangle your CORS error?

If this saved you an afternoon of guessing at medusa-config.ts, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all Medusa.js field notes