Safely Allowing localhost Origins in Development

Your local front end runs on http://localhost:3000 and calls a shared staging or production API. The browser blocks the request, even though the same API works fine for the deployed app. The fix is not to loosen the API for everyone — it is to add localhost to an allowlist that is gated to development builds and never shipped to production. This page is part of Wildcard Risks & Mitigation, which covers why a permissive * is the wrong answer to origin problems.

Exact Error This Page Resolves

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

The deployed front end at https://app.example.com is unaffected — only the local origin is rejected, because the server’s allowlist does not contain http://localhost:3000.

Root Cause

Under the Fetch Standard, an origin is the tuple of scheme, host, and port. Your staging API’s allowlist contains the deployed origins (https://app.example.com) but not the loopback origin your dev server presents. Three properties of that loopback origin trip people up:

Tracing both callers through the same API makes it obvious that nothing about loopback is special — the local origin simply is not on the list:

Two callers, one allowlist, two outcomes The deployed front end and the local dev server both send an Origin header to the same staging API. The allowlist contains the deployed origin and not the loopback origin, so the first response carries an echoed Access-Control-Allow-Origin and the second carries none. One staging API, two callers — membership of the list decides everything Deployed front end https://app.example.com Local dev server http://localhost:3000 Staging API allowlist https://app.example.com present — echoed back http://localhost:3000 absent — no entry matches Response readable ACAO echoes the origin script receives the body Response blocked no ACAO header at all Failed to fetch Origin Origin match no match Loopback is not treated specially — it fails the same string comparison every other origin faces

The wrong fix is to reflect any origin or set Access-Control-Allow-Origin: *. The right fix is a small allowlist that conditionally includes the loopback origins in development only.

Prerequisite State

Before applying the fix, confirm:

Environment-Gated Allowlist

Environment-gated CORS allowlist A decision flow where an environment flag branches to either the production base allowlist alone or the base list merged with loopback localhost origins in development. Base allowlist https origins NODE_ENV = development? DEV allowlist base + localhost + 127.0.0.1 PROD allowlist base only no localhost yes no
Figure — localhost origins are merged in only when the build is development.

Step-by-Step Fix

Step 1 — Define the base allowlist and the dev-only additions separately

Keep production origins in one list and loopback origins in another, so the boundary is explicit and auditable.

const PROD_ORIGINS = [
  'https://app.example.com'
];

// http is acceptable ONLY for loopback, ONLY in dev
const DEV_ORIGINS = [
  'http://localhost:3000',
  'http://127.0.0.1:3000'
];

Step 2 — Merge the dev origins only when the environment is development

const allowedOrigins = new Set(
  process.env.NODE_ENV === 'development'
    ? [...PROD_ORIGINS, ...DEV_ORIGINS]
    : PROD_ORIGINS
);

Because the merge is guarded by NODE_ENV, a production process never has the loopback origins in its Set. There is no runtime path that leaks them.

Step 3 — Reflect the validated origin in Express

app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (origin && allowedOrigins.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Vary', 'Origin');
    res.setHeader('Access-Control-Allow-Credentials', 'true');
  }
  if (req.method === 'OPTIONS') {
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
    res.setHeader('Access-Control-Max-Age', '600');
    return res.status(204).end();
  }
  next();
});

Step 4 — Equivalent Nginx: a separate dev include

In Nginx, keep the loopback origins in a map that only lives in the development config include, never in the production one. The base map is shared; the dev include adds the loopback lines.

# cors-dev.conf — included ONLY on the dev/staging server
map $http_origin $cors_origin {
  default                        "";
  "https://app.example.com"      $http_origin;
  "http://localhost:3000"        $http_origin;   # dev only
  "http://127.0.0.1:3000"        $http_origin;   # dev only
}
# cors-prod.conf — production has NO localhost entries
map $http_origin $cors_origin {
  default                        "";
  "https://app.example.com"      $http_origin;
}
server {
  location /api/ {
    add_header Access-Control-Allow-Origin      $cors_origin always;
    add_header Access-Control-Allow-Credentials true         always;
    add_header Vary                             Origin       always;

    if ($request_method = OPTIONS) {
      add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, OPTIONS' always;
      add_header Access-Control-Allow-Headers 'Content-Type, Authorization'     always;
      add_header Access-Control-Max-Age       600 always;
      return 204;
    }
    proxy_pass http://backend;
  }
}

The deploy pipeline includes cors-dev.conf on dev and staging and cors-prod.conf on production. The loopback map lines physically do not exist in the production config.

That is the whole point of splitting the file rather than branching at runtime — the artifact that reaches production has no loopback line to enable:

Which CORS include reaches which environment The repository holds two CORS include files. The deploy pipeline selects exactly one per target: the dev include, which adds loopback origins, ships to development and staging, while the production include, which lists only https origins, ships to production. The gate is a build-time selection, not a runtime condition Repository cors-prod.conf https origins only cors-dev.conf adds loopback origins Deploy pipeline copies exactly one include per target Development base list plus loopback origins Staging base list plus loopback origins Production base list only, no loopback entry Amber paths carry the loopback lines; the green path never had them to lose

Verification

Confirm the dev origin is accepted in development:

curl -si -X OPTIONS https://api.staging.example.com/v1/orders \
  -H "Origin: http://localhost:3000" \
  -H "Access-Control-Request-Method: POST" \
  | grep -i "access-control-allow-origin"

Expected in development:

access-control-allow-origin: http://localhost:3000

Then confirm the same request is rejected in production — the header must be absent:

curl -si -X OPTIONS https://api.example.com/v1/orders \
  -H "Origin: http://localhost:3000" \
  -H "Access-Control-Request-Method: POST" \
  | grep -i "access-control-allow-origin" || echo "no ACAO header (correct)"

DevTools check:

Security Boundary Note

Never leave a localhost or 127.0.0.1 entry in the production allowlist. If it ships, an attacker who can run code on the victim’s own machine — a malicious local app, a compromised dev tool, or a page that a proxy resolves to loopback — can make credentialed requests your production API will accept. And never substitute Access-Control-Allow-Origin: * to sidestep the problem: a wildcard cannot be paired with credentials at all, and it exposes the API to every origin on the internet. The http scheme is tolerable only for loopback origins that never traverse the network; every public origin must be https. For the deeper decision of when to reflect origins versus statically list them, see Wildcard vs Dynamic Origin Reflection: When to Use Each.

Common Mistakes

Issue Why It Fails
Adding localhost to the shared allowlist with no environment gate The loopback origin ships to production, where any local malicious process can make accepted cross-origin requests.
Allowing http://localhost but the dev server runs on 127.0.0.1 (or vice versa) They are different origins; the request from the untasted host is rejected. Add both explicitly.
Listing http://localhost without the port http://localhost:3000 is a distinct origin from http://localhost; the exact port must match.
Replacing the allowlist with Access-Control-Allow-Origin: * to make it work locally Wildcard is incompatible with credentials and opens the API to every origin; it converts a dev convenience into a production hole.

FAQ

Why is http://localhost:3000 blocked when http://localhost:8080 works?

The origin tuple is scheme, host, and port. A different port is a different origin under the Fetch Standard, so http://localhost:3000 and http://localhost:8080 are distinct origins. Your allowlist must contain the exact port your dev server uses, or you must match localhost with any port using an anchored pattern in dev only.

Are localhost and 127.0.0.1 the same origin?

No. Origin comparison is on the literal host string, not the resolved IP address. localhost and 127.0.0.1 are different hosts and therefore different origins even though they resolve to the same loopback address. If your tooling uses both, add both to the allowlist explicitly.

Is it safe to allow http origins for localhost?

Allowing http is acceptable only for localhost and 127.0.0.1, and only in development builds. Loopback addresses never traverse the network, so there is no transport to intercept. Never allow an http origin for any public hostname, and never let a localhost entry reach the production allowlist.