Why Browser-Based HTTP Testers Hit CORS Walls (and Postman Doesn't)
Why an HTTP tester running in your browser gets blocked by CORS while Postman sails through — and why proxying around it costs more than it looks.
by Dowon Oh
When I built the HTTP Request Tester for ToolRunner, I knew the first support question would be some variation of "why does this request work in Postman but fail here?" It is the single most predictable complaint about any browser-based API client, and it is not a bug in either tool. Postman's desktop app is a native process making raw HTTP requests; a browser tab is a sandboxed environment where every cross-origin request is subject to the same-origin policy and CORS. Same request, two completely different security models. In this article I want to walk through the actual mechanics — what triggers a preflight, why the browser reports the failure as an almost information-free TypeError, what web-based testers that "solve" CORS with a server proxy are actually trading away — and explain the deliberate decision I made for ToolRunner: detect the failure, explain it honestly, and hand you a working cURL command instead of routing your credentials through my server.
The same-origin policy, in one paragraph
The browser's baseline rule is that a script running on origin A (scheme + host + port) may not read responses from origin B. This is the same-origin policy, and it exists because your browser is a confused deputy waiting to happen: it holds your cookies, your intranet reachability, and your logged-in sessions, and it runs arbitrary JavaScript from whatever page you visit. Without the policy, any web page could quietly fetch your bank's API or your company's internal dashboard with your credentials attached and read the result. CORS — Cross-Origin Resource Sharing — is the controlled relaxation of that rule: a protocol by which the target server opts in to being read by scripts from other origins, using Access-Control-Allow-* response headers. The authoritative definition lives in the WHATWG Fetch Standard, and MDN's CORS guide is the readable version of it.
The part people miss: CORS is enforced by the browser on behalf of the server being called, not the page making the call. When ToolRunner's tester sends a request to api.example.com, the question the browser asks is "did api.example.com say that pages on toolrunner may read its responses?" If the answer is no, the browser withholds the response from my JavaScript — even if the request physically reached the server and got a perfectly good 200 OK back.
Simple requests and the preflight
CORS splits cross-origin requests into two tiers.
A "simple" request goes straight out on the wire. To qualify, it must use GET, HEAD, or POST; carry only CORS-safelisted headers (Accept, Accept-Language, Content-Language, Content-Type); and if it has a Content-Type, it must be one of exactly three values: application/x-www-form-urlencoded, multipart/form-data, or text/plain. The browser sends the request with an Origin header, then checks the response for Access-Control-Allow-Origin before releasing it to the page.
Everything else triggers a preflight: the browser first sends an OPTIONS request, on its own initiative, asking permission before the real request goes anywhere. The triggers are exactly the things an API tester exists to do — PUT, PATCH, DELETE, a Content-Type: application/json body, an Authorization header, an X-API-Key header. Add any one of those in the tester and you have left simple-request territory.
Here is what a preflight exchange looks like for a typical JSON PUT with a bearer token:
OPTIONS /v1/items/42 HTTP/1.1
Host: api.example.com
Origin: https://toolrunner.dev
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization, content-type
And a server that consents:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://toolrunner.dev
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400
Only after this handshake succeeds does the browser send the actual PUT. If the preflight comes back without those headers — or the server returns 404 for OPTIONS, which is extremely common on APIs that were never designed for browser consumption — the real request is never sent at all. Note the irony for anyone debugging: the API can be working flawlessly for every non-browser client on earth and still fail here, because the failure happens one request before the one you asked for.
There is one more asymmetry worth knowing even when the request succeeds: reading response headers is separately gated. By default, JavaScript can only read the CORS-safelisted response headers (Cache-Control, Content-Type, Content-Language, and a few others) unless the server lists more in Access-Control-Expose-Headers. ToolRunner's response panel iterates whatever response.headers exposes and prints a note under the list — "Some headers may be hidden by the server's CORS policy" — because that filtered view genuinely is all any browser tab can see. If you need the full header picture for an endpoint you control, the HTTP Header Analyzer is the better lens, and the security headers guide covers what should be in there.
Why the failure is deliberately opaque
When a CORS check fails, fetch rejects with TypeError: Failed to fetch (Chromium's wording) and nothing else. No status code, no response body, no distinguishing detail. Your first instinct is that this is a terrible API design, but it is intentional: if JavaScript could distinguish "CORS-blocked 200" from "connection refused" from "404", a malicious page could port-scan your internal network and probe authenticated endpoints by timing and classifying errors. The Fetch spec calls these filtered failures opaque — the network error the page sees is scrubbed of everything the page has no right to know. The full diagnostic detail goes to the DevTools console instead, where the user can read it but the page cannot.
This puts a browser-based tester in an awkward spot: the tool itself cannot know for certain that a failure was CORS. Here is exactly what ToolRunner's classifier does with a rejected fetch, from httpTesterHelpers.ts:
if (error instanceof TypeError && error.message === 'Failed to fetch') {
return {
type: 'cors',
message:
'Request failed — likely blocked by CORS policy. The server must include Access-Control-Allow-Origin headers.',
};
}
That word likely is load-bearing. A DNS failure, a refused connection, or an offline network can surface the identical TypeError, and no code running in the page can tell them apart. Timeouts and user cancellations are distinguishable — the tool races the fetch against AbortSignal.any([abortController.signal, AbortSignal.timeout(timeoutMs)]) (30 seconds by default) and classifies the resulting DOMException by name (TimeoutError versus AbortError) — so what remains in the ambiguous bucket is mostly CORS, which for arbitrary third-party APIs hit from a browser is by far the most common cause. The error banner says "Request Failed — Likely CORS Error" and states plainly that this is a browser security restriction, not a bug. I would rather ship an honest "likely" than a confident guess.
Why Postman never sees any of this
Postman, cURL, HTTPie, and your backend services never hit this wall for a simple reason: CORS is not part of HTTP. It is part of the browser. A native process opens a TCP connection, writes request bytes, reads response bytes, done. There is no Origin to protect, no ambient cookies being ridden, no same-origin policy to enforce, so there is nothing to check. (Postman's web version, tellingly, has the same problem every browser tool has — which is why it routes requests through a desktop agent or Postman's cloud servers.)
This is also why "the API works in Postman" and "the API works from my frontend" are different claims. If your production consumer is a browser app, testing from a native client skips the exact layer — preflights, allowed headers, exposed headers — that will decide whether your frontend works. A browser-based tester fails the same way your frontend will fail, which is occasionally exactly the signal you want.
The proxy "fix" and what it costs
There is a well-known way for a web-based tester to make CORS vanish: route every request through the tester's own backend. The browser talks to the tool's server (same origin or permissive CORS, no problem), the server makes the real request natively (no CORS, because no browser), and relays the response back. From the user's chair it looks like magic — every request just works.
Look at what actually happened, though. Your request — the target URL, every header, the full body, and critically your Authorization: Bearer … token, your Basic Auth password, your API key — traveled through someone else's server. You are trusting that operator not to log it, not to leak it in a breach, not to have an employee or a subpoena read it, and to actually delete it when they say they do. For a quick probe of a public endpoint, maybe fine. For an internal API bearing production credentials, that is a real exfiltration path, adopted casually because a security error was annoying. A proxy also physically cannot reach localhost or anything on your private network, and it subtly lies to you: the request now originates from a datacenter IP with proxy-added headers, so what you tested is not quite what your code will send.
ToolRunner's tester has no proxy and no server at all. sendRequest is a direct fetch(url, { method, headers, body, signal }) from your tab to the target — bearer tokens, Basic Auth (base64-encoded client-side with btoa), and API keys are assembled in your browser and travel only to the API you addressed. Request history lives in your browser's local storage, and the shareable-URL feature syncs the request into the query string — with credentials deliberately excluded from both. The Auth panel's fields (bearer token, Basic Auth credentials, API key) are never written to either, and header rows whose name is a credential carrier — Authorization, Cookie, Proxy-Authorization, plus anything ending in an api-key, auth-token, secret, or password style segment — are redacted before anything is persisted or shared, even if you typed them by hand instead of using the Auth panel. Entries saved before this redaction existed are cleaned the next time the tool loads. The live request still sends them; they just never outlive the tab. The request body is saved and shared as-is, so keep secrets out of it. The cost of the no-server architecture is the CORS wall, undisguised.
Detect, explain, hand over — the escape hatch
Since the tool will not proxy, the design goal became making the failure a fork in the road rather than a dead end. The page opens with a "Browser Security Notice" banner stating up front that cross-origin requests may be blocked by the target server's CORS policy. When a request fails the CORS classification, the error panel explains that the server did not include the required Access-Control-Allow-Origin headers — and then offers the actual fix: a Copy as cURL button, right inside the error banner.
The tester continuously builds a cURL command from your current request state — method, URL, headers, auto-set Content-Type, auth, body — so the command you copy is byte-for-byte the request the browser just tried to send, minus the browser. Paste it into a terminal, where CORS does not exist, and it runs. If you are moving in the other direction, the tool imports cURL commands too, and the standalone cURL Converter translates commands into fetch, Python, and other client code.
If you own the API and want browser clients — this tester included — to reach it, the fix is on the server: answer preflights and emit the Access-Control-Allow-* headers shown earlier, scoped to the origins you actually trust rather than a reflexive *. CORS headers are part of the same response-header hygiene as CSP and HSTS: declarations the server makes so the browser can enforce policy on its behalf. The wall is not the browser being obstinate. It is the server not yet having said yes — and I would rather tell you that plainly than quietly carry your credentials around it.
Sources
Specifications
Mozilla MDN
Further reading
- Complete Guide to HTTP Security Headers — the response-header side of browser-enforced policy.
- HSTS Explained — another header where the server declares and the browser enforces.
- What is Content-Security-Policy (CSP)? — the same trust model applied to what a page may load.