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.

Origin tuple comparison The origins http://localhost:3000 and http://127.0.0.1:8080 are split into scheme, host, and port. Scheme matches; host and port differ; therefore the request is cross-origin. scheme host port http localhost 3000 http 127.0.0.1 8080 match differ differ Any component differing → cross-origin → CORS applies
Figure — an origin is (scheme, host, port); host and port here differ, so the request is cross-origin.

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:

Local URLs judged against the page origin http://localhost:3000 Six rows list local API URLs fetched from a page on http://localhost:3000. Only the identical URL is same-origin; the loopback IPv4 address, a different port, a different scheme, the IPv6 loopback and a LAN address each differ in one component of the tuple. Page origin for every row: http://localhost:3000 URL the page fetches verdict component that differs http://localhost:3000/api same origin none — CORS never engages http://127.0.0.1:3000/api cross-origin host — a name versus an IPv4 literal http://localhost:8080/api cross-origin port https://localhost:3000/api cross-origin scheme (and the implied port) http://[::1]:3000/api cross-origin host — IPv6 literal, brackets included http://192.168.1.20:3000/api cross-origin host — and not a secure context One differing component is enough; the browser never checks where the two hosts resolve to

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

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:

Direct local call versus a dev-server proxy hop The upper row shows the browser calling a second local port directly, producing a cross-origin request that carries an Origin header. The lower row shows the same call routed through the dev server, whose server-to-server hop carries no Origin header, so CORS never applies. Direct call — the browser sees two origins Page in the browser http://localhost:3000 fetch Cross-origin request Origin: http://localhost:3000 CORS API server http://127.0.0.1:8080 Through the dev server's proxy — the browser sees one origin Page in the browser http://localhost:3000 /api/data Same-origin request no Origin header is sent proxied Dev server forwards to http://127.0.0.1:8080 The proxy hop leaves the browser's jurisdiction, so no Access-Control-* header is involved in it

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.