API reference
Fix CORS errors in an API documentation playground
Reproduce a blocked Try it request, read the preflight the browser sends, and fix it with a CORS policy on your API or Blume's built-in proxy.
By Hayden Bleasel11 min read

A request that works in curl but fails from your docs site is almost always CORS. curl sends the request and prints the answer. A browser first asks the API whether the docs site's origin may read it, with a separate preflight request whenever the call carries a bearer token or a JSON body. If the API doesn't answer with the right headers, the browser blocks the call, and the page only learns that the fetch failed.
By the end of this guide, you've reproduced the failure with two origins on your machine, read the browser's exact errors, and fixed them with a CORS policy you can copy into your API. You'll also know when Blume's built-in proxy is the better fix.
It applies to the Try it panel on Blume's OpenAPI, GraphQL, and hand-written API pages, and to other browser playgrounds: Swagger UI reports it as "Failed to fetch". If your spec's server is a path on the docs site, like /api, requests are same-origin and CORS never applies. To keep browsers off your API entirely, set playground: false and let readers copy the code samples.
Why curl works and the browser doesn't
An origin is a scheme, host, and port, so http://localhost:4321 and http://localhost:8787 are different origins, as are https://docs.acme.example and https://api.acme.example. When a page on one origin calls another, the browser applies CORS (Cross-Origin Resource Sharing): the API has to say, in response headers, that it allows the calling origin.
A plain GET, or a POST with a form body, goes straight to the API, and the browser hides the answer if those headers are missing. Anything else is preflighted: the browser first sends an OPTIONS request describing the real one. An Authorization header or a JSON Content-Type is enough, so nearly every Try it request is preflighted. The preflight asks three things:
| The preflight sends | The API must answer with |
|---|---|
Origin: http://localhost:4321 | Access-Control-Allow-Origin set to that exact origin, or * |
Access-Control-Request-Method: POST | Access-Control-Allow-Methods listing it (GET, HEAD, and POST are always allowed) |
Access-Control-Request-Headers: authorization,content-type | Access-Control-Allow-Headers listing each one |
The preflight must also get a 2xx status with no redirect, and the real response needs Access-Control-Allow-Origin too. curl never sends a preflight or checks these headers, so the same request succeeds there.
Reproduce it with two origins
Save this as api.mjs in any folder. It stands in for the Acme Messages API, with no dependencies and no CORS headers, and like many real APIs, it checks the bearer token before it routes anything.
import { createServer } from "node:http";
const messages = new Map();
const send = (res, status, body) => {
res.writeHead(status, {
"Content-Type": "application/json",
"X-Request-Id": crypto.randomUUID(),
});
res.end(JSON.stringify(body));
};
const readJson = async (req) => {
let text = "";
for await (const chunk of req) {
text += chunk;
}
try {
return JSON.parse(text);
} catch {
return null;
}
};
createServer(async (req, res) => {
const { pathname } = new URL(req.url, "http://localhost");
if (!req.headers.authorization?.startsWith("Bearer ")) {
return send(res, 401, { error: "Send an API key as a bearer token." });
}
if (req.method === "POST" && pathname === "/v1/messages") {
const body = await readJson(req);
if (!body?.to) {
return send(res, 400, { error: "Send channel, to, and template." });
}
const id = `msg_${messages.size + 1}`;
messages.set(id, { channel: body.channel, id, status: "queued" });
return send(res, 202, messages.get(id));
}
if (req.method === "GET" && pathname.startsWith("/v1/messages/")) {
const message = messages.get(pathname.slice("/v1/messages/".length));
if (message) {
return send(res, 200, message);
}
}
send(res, 404, { error: "Not found." });
}).listen(8787, () => {
console.log("Acme Messages API on http://localhost:8787");
});Start it with Node.js 22.12 or later:
node api.mjsIn a second terminal, send a message with curl:
curl -i http://localhost:8787/v1/messages \
-H "Authorization: Bearer test_key" \
-H "Content-Type: application/json" \
-d '{"channel":"email","to":"ada@example.com","template":"welcome"}'It answers 202 Accepted. Now the second origin: any Blume site with an openapi() reference that documents POST /messages, like the one from Generate API docs from an OpenAPI spec. Start it with npm run dev, at http://localhost:4321, then:
- Open the Send a message page at
/api/messages/send-message, and open Try it. - In Custom base URL, enter
http://localhost:8787/v1. It overrides the spec's server. - Type any value, like
test_key, into the token field under Authorization. - Open your browser's developer tools on the Console tab, then select Send.
The panel says the browser blocked the request, and tells you to set playground: { proxy: true } on the openapi() reference. That text is the same on every Try it panel, GraphQL and hand-written pages included, and it doesn't mention that the proxy needs a server build; the proxy section has the right setting for each. Before blaming CORS, Blume checks that something answers at that origin; if nothing does, it says "Couldn't reach the API" instead, which means a wrong URL or a stopped server. The console has the real reason. In Chrome:
Access to fetch at 'http://localhost:8787/v1/messages' from origin 'http://localhost:4321' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.Safari puts it as Preflight response is not successful. Status code: 401. Both are about the preflight, not your POST. In Chrome's Network tab, the POST shows CORS error in the Status column, and the preflight is its own OPTIONS row; set the filter to All if you can't see it. MDN lists Firefox's wording under CORS errors.
Read the preflight
Replay the preflight with curl, sending the headers Chrome sent:
curl -i -X OPTIONS http://localhost:8787/v1/messages \
-H "Origin: http://localhost:4321" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: authorization,content-type"The failing answer:
HTTP/1.1 401 Unauthorized
Content-Type: application/json
X-Request-Id: 00176cb4-3453-4750-96e9-3bb677d338ca
{"error":"Send an API key as a bearer token."}Two things are wrong. There's no Access-Control-Allow-Origin, and the status is 401, because the preflight ran into the token check. A preflight never carries the Authorization header, so an API that authenticates before it handles CORS turns every preflight away. Keep this command: with your docs origin in Origin, it tests a staging or production API the same way.
Fix the API's CORS policy
Save this beside api.mjs as cors.mjs:
// Origins allowed to call the API from a browser: scheme, host, and port,
// exactly as the browser sends them, with no path or trailing slash.
const ALLOWED_ORIGINS = new Set([
"https://docs.acme.example",
"http://localhost:4321",
]);
// Sets CORS headers on every response, and answers a preflight itself.
// Returns true when the request is fully handled.
export const applyCors = (req, res) => {
const { origin } = req.headers;
res.setHeader("Vary", "Origin");
if (!ALLOWED_ORIGINS.has(origin)) {
return false;
}
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Expose-Headers", "X-Request-Id");
const preflight =
req.method === "OPTIONS" && req.headers["access-control-request-method"];
if (!preflight) {
return false;
}
res.writeHead(204, {
"Access-Control-Allow-Headers": "Authorization, Content-Type",
"Access-Control-Allow-Methods": "GET, POST",
"Access-Control-Max-Age": "600",
});
res.end();
return true;
};Then wire it into api.mjs. Add the import below the first line:
import { applyCors } from "./cors.mjs";And make these the first lines inside the createServer handler, before the token check:
if (applyCors(req, res)) {
return;
}Restart the API and replay the preflight. Now it passes:
HTTP/1.1 204 No Content
Vary: Origin
Access-Control-Allow-Origin: http://localhost:4321
Access-Control-Expose-Headers: X-Request-Id
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Allow-Methods: GET, POST
Access-Control-Max-Age: 600Select Send in the panel again. It shows 202 Accepted, the response headers including x-request-id, and the queued message. What each part of cors.mjs does:
- The origin is echoed from an allowlist, because
Access-Control-Allow-Origintakes exactly one origin, written as the browser sends it. Vary: Originstops caches and CDNs serving one origin's answer to another.- The preflight is answered before authentication, since it never carries the token.
- Every response gets the headers, errors included, so a 401 or 404 reaches the panel as that status, not as a CORS error.
Access-Control-Allow-HeadersnamesAuthorization. Add any API key header, likeX-API-Key, and your spec's header parameters. MDN says*never coversAuthorization, even though some browsers accept it today, so list it by name.Access-Control-Allow-Methodsneeds any method beyond GET, HEAD, and POST, like PUT, PATCH, or DELETE.Access-Control-Max-Agelets the browser reuse a preflight answer for that many seconds, up to its own cap.Access-Control-Expose-Headerslets the page read headers beyond a basic few likeContent-Type. Without it, the panel never showsX-Request-Idor your rate-limit headers.
Credentials and allowed origins
In CORS, credentials means cookies and HTTP authentication the browser attaches on its own. The token a reader types into Try it isn't one: it's an ordinary header, which is why it belongs in Access-Control-Allow-Headers. The panel doesn't ask the browser to attach cookies to another origin either, so your API doesn't need Access-Control-Allow-Credentials. If it only authenticates with tokens in headers, Access-Control-Allow-Origin: * works for the panel too.
Keep an allowlist when the same API serves your own web app with cookie sessions. Those requests carry credentials, which rules out * (Chrome says it "must not be the wildcard '*' when the request's credentials mode is 'include'") and needs Access-Control-Allow-Credentials: true. Never pair that with an origin echoed back unchecked, or any website can make logged-in requests as your users and read the answers.
List every origin your docs are served from: production, http://localhost:4321 for blume dev, and your own preview hostnames, never a whole platform domain. If your API only accepts a cookie, the panel can't send it, since browsers don't let a page set a Cookie header. Blume tells the reader to run the code sample from a terminal instead.
In your framework
Most frameworks have middleware that does what cors.mjs does. Register it before authentication, and set the origins explicitly:
| Framework | Middleware | Watch for |
|---|---|---|
| Express | cors, with origin | Echoes whatever headers the preflight asks for unless you set allowedHeaders. Set exposedHeaders for response headers. |
| Fastify | @fastify/cors, with origin | Allows only GET, HEAD, and POST unless you set methods. |
| Hono | cors from hono/cors, with origin | Echoes requested headers unless you set allowHeaders. Set exposeHeaders for response headers. |
| FastAPI | CORSMiddleware, with allow_origins | Allows only GET and the basic headers by default. Set allow_methods and allow_headers, or the preflight gets a 400: "Disallowed CORS method, headers". |
| Django | django-cors-headers, with CORS_ALLOWED_ORIGINS | Put CorsMiddleware as high as possible, before middleware that can answer a request itself. |
Use the proxy when you can't change the API
When another team runs the API, or a gateway in front of it won't allow browser calls, send the panel's requests through a server instead. Blume's built-in proxy is a server route, so it needs a host adapter from blume/deploy:
import { defineConfig } from "blume";
import { vercel } from "blume/deploy";
import { openapi } from "blume/reference";
export default defineConfig({
deployment: vercel(),
reference: [
openapi({
playground: { proxy: true },
route: "/api",
spec: "./openapi.yaml",
}),
],
});Send then goes to /_api-proxy on the docs site's own origin (under your basePath, if you set one), with the target in a url query parameter, so CORS never applies. The docs server replays the request and relays the answer, marked with an x-blume-proxy: 1 header you can spot in the Network tab. The proxy is deliberately narrow:
- It needs server output. A static build stops with
BLUME_SERVER_FEATURE_REQUIRED. Name a host adapter, or dropoutput: "static"from the one you have. See server rendering. - It only calls your spec's servers. Blume builds its allowlist from every absolute
servers[].urlin your specs, with variables at their defaults, and checks each redirect against it too. A Custom base URL on another origin gets a 403. - It forwards only what the panel sets: the credential, header parameters, and the body's
Content-Type. Cookies, and headers your host adds, never reach the API. - It has limits. A body over 4 MB gets a 413, the proxy gives up on an API that doesn't answer within 30 seconds, and each reader is rate limited to 30 requests every 10 minutes by default.
Weigh what else changes: your docs are no longer static, readers' API keys pass through your docs deployment, and the API sees requests from your host instead of from each reader. When you own the API, fixing CORS is usually the smaller change.
To stay static, set proxy to the URL of a proxy you run. The panel sends it the real request with the target in the same url parameter, so that proxy must answer your docs origin with CORS headers itself, and should refuse targets that aren't your API.
Set the proxy for each kind of page
The panel's message always names openapi(), but the setting lives wherever the page comes from. Each one takes true for the built-in proxy or the URL of your own:
| Pages | Setting in blume.config.ts | Origins the built-in proxy allows |
|---|---|---|
| OpenAPI reference | openapi({ playground: { proxy: true } }) | Each absolute servers[].url in the spec |
| GraphQL reference | graphql({ endpoint, playground: { proxy: true } }) | The absolute endpoint. Without one, every send is refused, and the build warns. |
| Hand-written API pages | api: { playground: { proxy: true } } | api.server, and any full URL in a page's api frontmatter |
All of them share the one /_api-proxy route and the limits above. AsyncAPI pages have no proxy: their composer opens a WebSocket straight from the browser. A Scalar embed brings its own request client, which can't use Blume's proxy: fix CORS on the API, or pass Scalar's own proxyUrl option through scalar().
Troubleshooting
"It does not have HTTP ok status"
Chrome's message ends "Response to preflight request doesn't pass access control check: It does not have HTTP ok status." Something answered the OPTIONS request before your CORS code: an auth check (401), a router with no OPTIONS route (404 or 405), or FastAPI's middleware refusing a method or header it wasn't given (400). Safari names the status. Move CORS first, and replay the preflight until it returns a 2xx.
"Request header field authorization is not allowed"
Add the header the message names to Access-Control-Allow-Headers. It's usually authorization or an API key header.
The allowed origin doesn't match
Chrome says the header "has a value ... that is not equal to the supplied origin" and quotes it. A trailing slash like 'http://localhost:4321/' fails, and so do http instead of https, a wrong port, or a missing www. Listing several origins fails too ("contains multiple values"). Echo the one that matched.
"No 'Access-Control-Allow-Origin' header" with no mention of the preflight
The preflight passed, but the real response had no CORS headers. It's usually an error from a layer that runs before your CORS code, like auth middleware or a gateway. Safari shows its status, as in Status code: 401. Repeat the request with curl and an Origin header, and make the error carry the headers too.
"Redirect is not allowed for a preflight request"
The server URL redirects, often from http to https, to add or drop a trailing slash, or from an old domain. Browsers won't follow a redirect on a preflight, so put the final URL in your spec's servers.
The proxy answers 403
The target isn't an origin the proxy allows, which is expected for a Custom base URL. For a documented server, check that it's an absolute URL: a relative server like /v1, a variable with no default, a GraphQL reference with no endpoint, or a relative api.server adds no origin, and blume build warns that the proxy will refuse every request.
Next step
Replay your API's preflight
Send your staging or production API the preflight a browser would, with your docs origin, and check it answers 2xx with the headers from this guide.
Read the playground CORS docsA step here not working for you? Report a broken step.