CORS Error Calling an AI API from the Browser? It's Not a Bug — Here's the Fix
"No 'Access-Control-Allow-Origin' header is present on the requested resource" when you call OpenAI or Anthropic straight from client-side JavaScript. Why the API is designed to block you, why every CORS workaround is dangerous, and how to build a proper backend proxy — with streaming.
Prismix tracks OpenAI, Anthropic and other AI API status live
This error is a design choice, not an outage — but if requests from your proxy start failing too, check status first. Full incident history →
Why this happens and how to fix it
1. The error, and why it's not a misconfiguration
Calling api.openai.com/v1/chat/completions or api.anthropic.com/v1/messages directly from a browser fetch() produces:
Access to fetch at 'https://api.openai.com/v1/chat/completions' from origin
'https://myapp.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin'
header is present on the requested resource.
Access to fetch at 'https://api.anthropic.com/v1/messages' from origin
'https://myapp.com' has been blocked by CORS policy: Response to preflight request
doesn't pass access control check.
There is no header you can add on your side to fix this — the missing header lives on OpenAI's and Anthropic's servers, and they will not add it for these endpoints. Any browser request needs an Authorization: Bearer sk-... header, and putting that key in client JS means it ships to every visitor's browser, readable in the Network tab and in the compiled bundle. Bots scrape published API keys from public sites within hours. Blocking the browser origin is the provider protecting you from an unbounded bill.
2. Anti-patterns to avoid — they don't actually fix anything
- Public CORS-proxy services (cors-anywhere, corsproxy.io, allorigins) — you'd be sending your secret API key through a third-party server you don't control. Anyone running that proxy can log every request header, including your key.
- Browser CORS-disabling extensions — only disables the check on your own machine during development. Every real user still gets blocked, and the key is still exposed the moment devtools opens.
dangerouslyAllowBrowser: truein the OpenAI SDK — this flag exists specifically to override the SDK's built-in safety check that throws when you instantiate the client outside a server. Setting it in client code does not add security, it removes the SDK's warning about a genuinely insecure setup.
3. The correct fix — a minimal backend proxy
The browser calls your own server; your server (which never ships to the client) holds the key and calls OpenAI/Anthropic. Next.js API route:
// app/api/chat/route.ts
// Runs on the server only — process.env.OPENAI_API_KEY is never sent to the client
export async function POST(req: Request) {
const { messages } = await req.json();
const upstream = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: "gpt-4o-mini",
messages,
}),
});
if (!upstream.ok) {
return new Response(await upstream.text(), { status: upstream.status });
}
return new Response(upstream.body, {
headers: { "Content-Type": "application/json" },
});
} Astro API route (same idea, Astro's file convention):
// src/pages/api/chat.ts
import type { APIRoute } from "astro";
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
const { messages } = await request.json();
const upstream = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": import.meta.env.ANTHROPIC_API_KEY,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({ model: "claude-3-5-haiku-20241022", max_tokens: 1024, messages }),
});
return new Response(upstream.body, { status: upstream.status });
}; Cloudflare Worker:
export default {
async fetch(request: Request, env: { OPENAI_API_KEY: string }) {
if (request.method !== "POST") return new Response("Method not allowed", { status: 405 });
const { messages } = await request.json();
const upstream = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${env.OPENAI_API_KEY}`,
},
body: JSON.stringify({ model: "gpt-4o-mini", messages }),
});
return new Response(upstream.body, {
status: upstream.status,
headers: { "Access-Control-Allow-Origin": "https://myapp.com" },
});
},
}; Express endpoint:
app.post("/api/chat", async (req, res) => {
const upstream = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: JSON.stringify({ model: "gpt-4o-mini", messages: req.body.messages }),
});
res.status(upstream.status);
upstream.body.pipe(res); // Node stream pipe — forwards chunks as they arrive
}); 4. Streaming through the proxy without breaking the UX
Set stream: true in the request to OpenAI/Anthropic, then hand the upstream ReadableStream straight through as your response body — don't await upstream.text() or you'll buffer the whole reply and lose token-by-token rendering:
// app/api/chat/route.ts — streaming variant
export async function POST(req: Request) {
const { messages } = await req.json();
const upstream = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: JSON.stringify({ model: "gpt-4o-mini", messages, stream: true }),
});
// Pass the SSE stream through unmodified; the client reads it exactly
// as it would read OpenAI's response directly.
return new Response(upstream.body, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
On the client, read /api/chat the same way you'd read the provider's SSE stream — the only change is the URL. No buffering, no added latency beyond one extra network hop.
5. Keeping the key out of the client bundle
- Next.js: use
OPENAI_API_KEY(noNEXT_PUBLIC_prefix). Any env var prefixedNEXT_PUBLIC_is inlined into the client bundle at build time and is public by design. - Astro: read server-only secrets with
import.meta.envonly inside files undersrc/pages/api/or server-rendered frontmatter withexport const prerender = false; never in a<script>tag or client island. - Vite/CRA: only variables prefixed
VITE_orREACT_APP_reach the browser — never put an API key under one of those prefixes. - Sanity check: after building, search the compiled output (
grep -r "sk-" dist/) for your key prefix. If it shows up in any client-served file, the key is public.
Get an email the next time OpenAI goes down
Outage alerts for OpenAI, straight to your inbox. No account needed, unsubscribe in every email.
Watching more than one service? A free account covers 5 services + a daily digest — and Pro is currently free.
FAQ
Why does the OpenAI API block direct browser requests with a CORS error?
OpenAI and Anthropic do not send an Access-Control-Allow-Origin header for browser requests to their completion endpoints, by design. Any API key embedded in client JS is visible to every visitor via devtools and gets scraped and abused within hours. Blocking the browser origin forces the request through a server you control, where the key stays hidden.
Can I fix the CORS error with a browser extension or cors-anywhere proxy?
You can make the error disappear, but you shouldn't. A CORS-disabling extension only works on your own machine, not your users'. A public proxy like cors-anywhere relays your Authorization header through a third-party server that can log or leak your key. Both are workarounds for a request that should never leave a trusted backend.
What does OpenAI's dangerouslyAllowBrowser flag actually do?
It silences the SDK's built-in check that throws when you instantiate the OpenAI client outside a server context. It doesn't add any real protection — the key is still fully readable in the compiled bundle and network tab. Only set it in server-side code, never in a component shipped to the client.
How do I stream an AI response through a backend proxy without losing the streaming UX?
Set stream: true on the upstream request and pass the upstream ReadableStream straight through as your own response body instead of buffering it. The client reads your proxy's endpoint with the same SSE/fetch-reader pattern it would use against the provider directly.