Why localhost and 127.0.0.1 Are Different Origins
A request from http://localhost:3000 to an API on http://127.0.0.1:8080 is blocked by CORS, even though both point to the same machine. This trips up nearly every developer at some point, and the cause is not a bug — it is the exact definition of an origin. localhost and 127.0.0.1 are different host strings, and CORS compares hosts as strings, never as resolved addresses.
This page is part of Origin Matching Rules & Validation, which covers how browsers evaluate the origin tuple in full.
The Symptom This Resolves
Access to fetch at 'http://127.0.0.1:8080/api/data' from origin
'http://localhost:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin'
header is present on the requested resource.
The same fetch works when both the page and the API use localhost, and breaks the moment one side switches to 127.0.0.1 — or to a different port.
Root Cause
The WHATWG Fetch Standard and the URL Standard define an origin as the tuple (scheme, host, port). Two origins are the same only when all three components are identical, and the host is compared as an exact ASCII string after parsing — the browser does not perform DNS resolution or consult the hosts file when comparing origins. So localhost and 127.0.0.1 are distinct hosts, http and https are distinct schemes, and :3000 and :8080 are distinct ports. Any difference makes the request cross-origin and subjects it to CORS. This is the same exact-string comparison described in How Browsers Evaluate Same-Origin Policy.
Which local addresses actually match
The host component is whatever the URL parser produced, serialized back into the Origin header verbatim. localhost parses as a domain, 127.0.0.1 parses as an IPv4 address, and ::1 parses as an IPv6 address that is serialized with its brackets — so the three loopback spellings that a developer treats as interchangeable produce three unrelated strings. Aliases you create yourself behave the same way: adding 127.0.0.1 myapp.local to /etc/hosts gives you a host that resolves to the loopback interface but is a fourth distinct origin, and browsers apply the same rule to the *.localhost names that some tooling hands out. Binding a server to 0.0.0.0 does not help either — that is a listen address, not a name a page can be served from; the browser records whichever host you typed in the address bar.
Every combination below is the same page on http://localhost:3000 calling something on the same machine:
What localhost gets that the other spellings do not
Two browser behaviours are attached to the name rather than to the machine, which is another reason to standardize on one spelling. First, secure contexts: the W3C Secure Contexts specification treats http://localhost and http://127.0.0.1 as potentially trustworthy origins, so APIs gated behind HTTPS — service workers, getUserMedia, the Web Crypto subtle API — work over plain HTTP on both, while the same code served from http://192.168.1.20 is refused. Secure cookies are likewise accepted over http://localhost in current Chrome and Firefox. Second, Private Network Access: when a page loaded from a public origin fetches a loopback or private address, Chrome sends a preflight carrying Access-Control-Request-Private-Network: true, and the local server must answer with Access-Control-Allow-Private-Network: true in addition to the usual Access-Control-Allow-Origin. A local device API that works when you open it from http://localhost and fails from your deployed staging site is hitting that check, not the origin comparison described above.
Prerequisite State
- A local front-end and a local API running on the same machine.
- You can read the exact
Originheader of the failing request (DevTools → Network → the request → Headers). - You can edit the API’s CORS allowlist.
Step-by-Step
Step 1 — Read the exact Origin
In the failing request’s headers, note the exact Origin, including the port — for example http://localhost:3000. That exact string is what the API must allow.
Step 2 — Pick one canonical dev origin
Standardize your tooling on a single host and port. If the front-end runs on http://localhost:3000, configure it to call the API at http://localhost:8080, not http://127.0.0.1:8080. Consistency alone resolves most of these failures.
Step 3 — Allow the exact dev origins (development only)
If you genuinely need both, list the exact origins in a development-only allowlist. This pattern, and the danger of shipping it, is covered in Safely Allowing localhost Origins in Development:
const DEV_ORIGINS = new Set([
'http://localhost:3000',
'http://127.0.0.1:3000',
]);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (process.env.NODE_ENV !== 'production' && DEV_ORIGINS.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin');
}
next();
});
Step 4 — or delete the cross-origin call entirely
The most durable fix is to stop making a cross-origin request in development at all. Every mainstream dev server can proxy a path prefix to the API, so the browser only ever sees a same-origin URL and the whole comparison becomes moot:
// vite.config.js — the page fetches '/api/data', never http://127.0.0.1:8080
export default {
server: {
proxy: {
'/api': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
},
},
},
};
The forwarded hop is server to server. The dev server issues a fresh HTTP request that carries no Origin header at all, so the API applies no CORS logic and needs no allowlist entry for your machine. changeOrigin: true rewrites the Host header to match the target, which matters when the API routes by virtual host. The trade-off is that development no longer exercises the CORS configuration you ship, so keep at least one direct-origin smoke test against staging.
Side by side, the two setups differ in exactly one thing — how many origins the browser can see:
Verification
curl -si http://127.0.0.1:8080/api/data \
-H "Origin: http://localhost:3000" | grep -i access-control-allow-origin
# → access-control-allow-origin: http://localhost:3000
Security Boundary Note
Do not “solve” the mismatch with Access-Control-Allow-Origin: * or by reflecting any origin — a wildcard also breaks the moment you add credentials, and blind reflection lets any site call your API. Keep the localhost entries in a development-only branch and ensure they are stripped from production builds; a stray http://localhost:3000 left in a production allowlist is an opening for a local malware page to reach your API. See Wildcard vs Dynamic Origin Reflection for the general rule.
Common Mistakes
| Issue | Technical impact | Mitigation |
|---|---|---|
Assuming localhost == 127.0.0.1 for CORS |
Requests break unpredictably between the two | Treat them as distinct origins; standardize on one |
| Forgetting the port is part of the origin | :3000 → :8080 requests are cross-origin and blocked |
List the exact origin with its port in the allowlist |
| Leaving localhost origins in production | Local pages can reach the production API | Gate dev origins behind a NODE_ENV check |
Allowlisting http://localhost without a port |
The entry never matches, because the browser always sends the port it is served from | List the full origin, e.g. http://localhost:3000 |
Assuming [::1] is covered by a 127.0.0.1 entry |
A machine that resolves localhost to IPv6 first produces a third origin string |
Add http://[::1]:3000 too, or pin the dev server to IPv4 |
FAQ
If localhost and 127.0.0.1 point to the same machine, why does CORS treat them differently?
CORS never resolves hostnames to IP addresses. An origin is the tuple (scheme, host, port), and the host is compared as an exact ASCII string. localhost and 127.0.0.1 are different host strings, so http://localhost:3000 and http://127.0.0.1:3000 are different origins regardless of DNS or the hosts file mapping them to the same address.
Does the port count as part of the origin in development?
Yes. The port is part of the origin tuple, so http://localhost:3000 and http://localhost:5173 are different origins, and a request from a dev server on 5173 to an API on 3000 is cross-origin and needs CORS headers. This is one of the most common local development surprises.
Should I add both localhost and 127.0.0.1 to my dev allowlist?
Pick one canonical dev origin and use it consistently. If your team genuinely uses both, add both exact origins (with ports) to a development-only allowlist. Never ship that allowlist to production and never replace it with a wildcard to sidestep the problem.
Why do HTTPS-only browser APIs work on http://localhost but not on my LAN IP?
That is the secure-context rule, which is separate from origin matching. The W3C Secure Contexts specification treats http://localhost and http://127.0.0.1 as potentially trustworthy, so service workers, getUserMedia and the Web Crypto subtle API run over plain HTTP there. A private address such as http://192.168.1.20 is not on that list, so the same page served to a phone on your network loses those APIs even though nothing about CORS changed.
Does a dev-server proxy remove the need for CORS headers?
In development, yes. When the Vite, Next.js or Create React App dev server proxies /api to your backend, the browser only ever requests a same-origin URL, and the forwarded hop is a server-to-server request that carries no Origin header, so the backend applies no CORS logic. The caveat is that your production CORS configuration is then never exercised locally, so test the real cross-origin path against a staging deployment before you ship.