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:
- The port matters.
http://localhost:3000andhttp://localhost:5173are different origins. An entry for one does not cover the other. localhostand127.0.0.1are different origins. Origin comparison is on the literal host string, not the resolved IP, so the two are distinct even though both point at the loopback interface — the same reasoning behind why localhost and 127.0.0.1 are different origins.- The scheme is
http, nothttps. Dev servers usually serve plaintext, so the origin ishttp://…, which the production allowlist (allhttps://) rightly excludes.
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:
- You know the exact dev origin from the console error — scheme, host, and port (e.g.
http://localhost:3000). - The API already has a working production allowlist that reflects the exact
Originand is not using*— the baseline described in the parent pillar, Server-Side CORS Configuration & Header Management. - You have a reliable environment signal, such as
NODE_ENVfor Node or a separate Nginx config include per environment, so dev-only entries can be gated out of production.
Environment-Gated Allowlist
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.
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.