Why My API Works in Postman but Not in Browser — Complete Debugging Guide
You test your API in Postman — works perfectly. You paste the same URL into JavaScript — and it crashes. This is one of the most common frustrations in web development, and it has a handful of well-known root causes. This guide covers every one of them with working fixes.
#1
cause: missing CORS headers
95%
of cases solved by 3 fixes
5+
root causes covered here
2 min
to diagnose with this guide
Why Postman Succeeds Where the Browser Fails
Postman is a native application or browser extension that sends requests directly, without any browser security model applied. Your browser, on the other hand, enforces several security policies that Postman blissfully ignores.
| Item | Postman / curl | Browser Fetch / XHR |
|---|---|---|
| CORS enforced? | ❌ Never | ✅ Always |
| Preflight OPTIONS? | ❌ Never | ✅ For non-simple requests |
| Cookie scope | Sends any cookie you add | SameSite / Domain rules apply |
| Auth headers | Always sent | Blocked if CORS not set up |
| Mixed content | Not applicable | HTTP blocked on HTTPS pages |
| Origin header | None (or manual) | Auto-set by browser |
Quick fact
The browser is not broken — it is enforcing security policies that protect your users. The fix almost always lives on the server, not in your frontend code.
Root Cause 1 — CORS (Cross-Origin Resource Sharing)
CORS is by far the most common culprit. The browser checks whether the server explicitly permits requests from your frontend's origin (scheme + host + port). If the server doesn't include the right response headers, the browser blocks the response — even if the server returned 200 OK.
Browser sends request
Server responds (any status)
Browser checks Access-Control-Allow-Origin
Header matches origin?
Allow — JS receives response
Classic CORS error in console
The fix is always server-side. Add the correct response headers:
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true # only if you send cookiesHere's how to set this in common backend frameworks:
const cors = require('cors');
app.use(cors({
origin: 'https://myapp.com', // your frontend origin
methods: ['GET','POST','PUT','DELETE'],
allowedHeaders: ['Content-Type','Authorization'],
credentials: true, // only if using cookies
}));from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://myapp.com"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)Do NOT use wildcard (*) with credentials
Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true cannot be combined. Use an explicit origin instead.Root Cause 2 — Preflight OPTIONS Request
For any request with a custom header (like Authorization) or a non-GET/POST method, the browser first sends a silent OPTIONS preflight to ask the server for permission. If the server doesn't handle OPTIONS, it returns 405 Method Not Allowed, and the real request never fires.
Missing OPTIONS handler
# Server ignores OPTIONS — preflight fails
app.post('/api/data', handler)
# No OPTIONS route definedOPTIONS handled
# Express: cors() handles OPTIONS automatically
app.use(cors(corsOptions));
# Or manually:
app.options('*', cors(corsOptions)); // enable preflight for all routes
app.post('/api/data', cors(corsOptions), handler);How to spot a preflight issue
Root Cause 3 — Auth Headers Not Sent
Postman lets you manually attach any header. The browser's fetch() API requires you to explicitly include credentials in your code. A missing Authorization header is the second most common issue after CORS.
No auth header
// Auth header forgotten — server returns 401
fetch('https://api.example.com/data')
.then(res => res.json())Auth header included
// Include Authorization header explicitly
const token = localStorage.getItem('token');
fetch('https://api.example.com/data', {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
})
.then(res => res.json())Root Cause 4 — Cookies & Credentials
By default, fetch() does not send cookies. You must opt in withcredentials: 'include'. The server must also explicitly allow credentials in its CORS headers, and the cookie must meet SameSite requirements.
Cookies not sent
// Cookies not sent — session breaks
fetch('/api/user/profile')
.then(res => res.json())Cookies included
// Send cookies with every request
fetch('/api/user/profile', {
credentials: 'include', // sends cookies
})
.then(res => res.json())
// Server must respond with:
// Access-Control-Allow-Credentials: true
// Access-Control-Allow-Origin: https://myapp.com (exact, no wildcard)credentials: "omit"
Default — no cookies, no TLS certs sent.
credentials: "same-origin"
Cookies only to same domain. Good default for most apps.
credentials: "include"
Always send cookies. Required for cross-origin sessions.
SameSite=None; Secure
Cookie attribute required for cross-site cookies in modern browsers.
Root Cause 5 — Mixed Content (HTTP vs HTTPS)
If your frontend is served over HTTPS but your API is HTTP, the browser blocks the request entirely. This is called mixed content and is a security protection — not a bug.
Mixed content error
Upgrade your API to HTTPS
Use a free Let's Encrypt certificate. This is the correct long-term fix.
Use a reverse proxy
Serve your HTTP API behind an Nginx/Cloudflare HTTPS proxy.
Use environment variables for API base URL
Set API_URL=https://... in production so the URL is always HTTPS in prod.
const API = process.env.NEXT_PUBLIC_API_URL || "https://api.example.com";Temporary: serve frontend over HTTP too
Not recommended for production — only for local development.
Root Cause 6 — Content-Type Header
Sending a JSON body without Content-Type: application/json causes the server to misread the body. This makes the request fail on the server side, while it worked in Postman because Postman auto-sets it.
Missing Content-Type
// Body is sent as plain text — server can't parse it
fetch('/api/users', {
method: 'POST',
body: JSON.stringify({ name: 'Alice', age: 30 }),
})Content-Type set
// Correct: tell the server you're sending JSON
fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ name: 'Alice', age: 30 }),
})Diagnostic Checklist
Open DevTools → Network tab
Find your request. Look at the Response Headers for Access-Control-Allow-Origin.
Check the Console tab
CORS errors, mixed-content warnings, and auth failures all appear here with exact details.
Look for the OPTIONS preflight
If you see a red OPTIONS request before your main request — preflight is failing.
Check the request headers
Click your request → Headers tab → verify Authorization and Content-Type are present.
Check the response status
401 = auth missing/wrong. 403 = forbidden. 0 = CORS blocked (browser won't show response).
Confirm API URL uses HTTPS if frontend does
Check the request URL in the network tab — http:// on an https:// page is instant block.
Quick Reference: Error Messages Decoded
| Item | Error Message | Root Cause & Fix |
|---|---|---|
| No Access-Control-Allow-Origin header | CORS error | Add CORS headers on server |
| Response to preflight has invalid HTTP status | Preflight (OPTIONS) fail | Handle OPTIONS on server |
| 401 Unauthorized | Auth header missing | Add Authorization header in fetch() |
| Mixed Content blocked | HTTP API from HTTPS page | Use HTTPS for API |
| Failed to parse JSON body | Content-Type missing | Add Content-Type: application/json |
| Request blocked by cookie policy | SameSite/credentials mismatch | Use credentials: include + SameSite=None |
Development Proxy: The Full Workaround
During local development, you can use a proxy to avoid CORS entirely by making the browser think the API is on the same origin:
/** @type {import('next').NextConfig} */
const nextConfig = {
async rewrites() {
return [
{
source: '/api/:path*',
destination: 'http://localhost:8000/:path*', // your backend
},
];
},
};export default {
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^/api/, ''),
},
},
},
};Best practice for production