Retool makes it deceptively easy to wire up APIs fast. That’s great right up until the browser starts yelling about CORS and half the team decides “the API is broken.”
Usually, the API is fine. The browser is doing exactly what it should do, and your Retool app is running into the same cross-origin rules as any other frontend.
I’ve seen the same mistakes over and over with Retool setups: wrong origin assumptions, broken preflight handling, credentials mixed with wildcards, and APIs that technically work in Postman but fail instantly in the browser. Here’s the stuff that trips people up most often, and how I’d fix it.
Mistake 1: Testing in Postman and assuming the browser will behave the same
This is the classic one.
You hit an API from Postman or curl, it returns 200 OK, and everybody assumes Retool should work too. Then the browser says:
No ‘Access-Control-Allow-Origin’ header is present on the requested resource
That’s not a contradiction. Postman is not a browser. CORS is enforced by browsers.
If your Retool app is making a client-side request, the API must return the right CORS headers for the Retool app’s origin.
A real example: api.github.com sends:
access-control-allow-origin: *
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset
That’s why browser-based requests to GitHub’s API often work cleanly for public endpoints. The API is explicitly allowing cross-origin access.
Fix
Check the response headers in the browser network tab, not just the response body in a tool.
If your Retool app is browser-side and the API response does not include something like:
Access-Control-Allow-Origin: https://your-retool-app.example.com
or
Access-Control-Allow-Origin: *
then the browser will block access.
If you control the API, add the right header. If you don’t, move the request server-side through Retool’s backend resource or your own proxy.
Mistake 2: Using * with credentials
This one burns a lot of teams because it looks reasonable at first glance.
They configure:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
That does not work. Browsers reject it.
If cookies, HTTP auth, or other credentials are involved, you cannot use a wildcard origin. The server has to return a specific origin.
Broken config
app.use((req, res, next) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Credentials", "true");
next();
});
Fix
Reflect or explicitly allow the actual Retool origin.
const allowedOrigins = [
"https://your-retool-domain.retool.com",
"https://your-custom-retool-domain.com"
];
app.use((req, res, next) => {
const origin = req.headers.origin;
if (allowedOrigins.includes(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Vary", "Origin");
}
next();
});
Vary: Origin matters here. Without it, caches can serve the wrong CORS response to the wrong origin.
If your Retool app needs session cookies, this is the pattern you want.
Mistake 3: Forgetting that preflight requests exist
A request works fine as a simple GET, then someone adds an Authorization header or changes the method to PATCH, and suddenly CORS starts failing.
That’s a preflight problem.
Browsers send an OPTIONS request before the real request for anything non-simple. If your API doesn’t answer the preflight correctly, the actual request never happens.
Typical browser preflight:
OPTIONS /api/users/123 HTTP/1.1
Origin: https://your-retool-domain.retool.com
Access-Control-Request-Method: PATCH
Access-Control-Request-Headers: authorization, content-type
Your server must respond with matching permissions.
Fix
Handle OPTIONS properly.
app.options("/api/*", (req, res) => {
const origin = req.headers.origin;
if (origin === "https://your-retool-domain.retool.com") {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Access-Control-Max-Age", "600");
res.setHeader("Vary", "Origin");
}
res.sendStatus(204);
});
If you use Express with the official cors middleware, keep it boring and explicit:
const cors = require("cors");
app.use(cors({
origin: ["https://your-retool-domain.retool.com"],
credentials: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Authorization", "Content-Type"]
}));
Official docs for that package are here: https://expressjs.com/en/resources/middleware/cors.html
Mistake 4: Sending custom headers and not allowing them
Retool queries often send headers like:
AuthorizationContent-Type: application/json- custom tenant headers
- API version headers
If the browser preflight asks for those headers and the API doesn’t include them in Access-Control-Allow-Headers, the request dies.
A lot of people only allow Content-Type and then wonder why bearer auth fails.
Fix
Match what the browser is asking for.
If the preflight says:
Access-Control-Request-Headers: authorization, content-type, x-org-id
the response needs to allow them:
Access-Control-Allow-Headers: Authorization, Content-Type, X-Org-Id
Case is generally normalized by browsers, but I still prefer writing them cleanly and explicitly.
Here’s a safe Express example:
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin === "https://your-retool-domain.retool.com") {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Org-Id");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
res.setHeader("Vary", "Origin");
}
if (req.method === "OPTIONS") {
return res.sendStatus(204);
}
next();
});
Mistake 5: Not exposing response headers that Retool needs
This one is sneaky because the request succeeds, but your Retool code can’t read certain response headers.
By default, JavaScript can only read a limited set of response headers. If you need things like pagination links, rate limit info, or ETags, the API must expose them.
GitHub does this well. Their API exposes useful headers including:
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset
That means frontend code can read Link for pagination and X-RateLimit-Remaining for quota handling.
Fix
If your Retool app needs a header, expose it.
app.use((req, res, next) => {
res.setHeader(
"Access-Control-Expose-Headers",
"ETag, Link, X-RateLimit-Remaining, X-Request-Id"
);
next();
});
If your table pagination depends on a Link header and you forgot to expose it, Retool won’t be able to read it even though the browser received it.
Mistake 6: Treating CORS as authentication
CORS is not auth. It is not access control in the way people mean it in backend systems. It tells the browser whether frontend JavaScript may read a response.
If a server returns sensitive data to any unauthenticated request, CORS doesn’t magically secure it. It just affects whether a browser script from another origin can access it.
I still see teams say things like “we’re safe because only our Retool domain is allowed.” That’s not a security boundary for the API itself.
Fix
Use real authentication and authorization on the API:
- bearer tokens
- session validation
- per-user authorization checks
- tenant scoping
Then configure CORS separately.
If you’re reviewing broader security headers too, that’s where a reference like https://csp-guide.com can help for CSP and related browser protections. Different problem, different control.
Mistake 7: Returning CORS headers only on success responses
Another common bug: your API sets Access-Control-Allow-Origin on 200 OK, but not on 401, 403, or 500.
The result is miserable debugging. Retool shows a vague CORS failure, and you never see the real error body because the browser blocks access to it.
Fix
Add CORS headers consistently, including error responses.
Bad pattern:
app.get("/api/data", (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "https://your-retool-domain.retool.com");
if (!req.user) {
return res.status(401).json({ error: "Unauthorized" });
}
res.json({ ok: true });
});
Better pattern: set CORS at middleware level before route handling, so every response gets it.
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin === "https://your-retool-domain.retool.com") {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin");
}
next();
});
Now your 401 and 500 responses are visible to the frontend too.
Mistake 8: Solving browser CORS problems in the wrong layer
Sometimes the right answer is not “fix the API.” Sometimes it’s “stop making this request from the browser.”
This comes up a lot in Retool when dealing with:
- internal APIs not meant for browsers
- APIs that will never allow your Retool origin
- secret API keys you absolutely should not expose client-side
- legacy services with bad
OPTIONShandling
Fix
Move the request to a server-side execution path.
In practice, that often means using a Retool resource that runs from the backend instead of directly from the browser, or placing a small proxy in front of the upstream API.
Example proxy in Node:
app.get("/proxy/github-user", async (req, res) => {
const response = await fetch("https://api.github.com/user", {
headers: {
"Authorization": `Bearer ${process.env.GITHUB_TOKEN}`,
"Accept": "application/vnd.github+json"
}
});
const data = await response.json();
res.json(data);
});
Now the browser talks only to your origin, and your server talks to GitHub. No browser-side CORS issue, and your token stays off the client.
A practical Retool debugging checklist
When I debug CORS in Retool, I check these in order:
- What origin is the app actually running on?
- Is the request browser-side or server-side?
- Does the response include
Access-Control-Allow-Origin? - If credentials are used, is the origin explicit instead of
*? - Did the browser send a preflight
OPTIONSrequest? - Does the preflight response allow the method and headers being requested?
- Are needed response headers exposed with
Access-Control-Expose-Headers? - Do error responses include CORS headers too?
That checklist catches most failures fast.
For the underlying browser behavior, the official CORS reference is still the best place to sanity check details: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
CORS in Retool isn’t special. It’s just regular browser CORS with a low-code UI on top. Once you stop treating it like magic, the fixes get pretty boring. And boring is exactly what you want in production.