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:

  • Authorization
  • Content-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 OPTIONS handling

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:

  1. What origin is the app actually running on?
  2. Is the request browser-side or server-side?
  3. Does the response include Access-Control-Allow-Origin?
  4. If credentials are used, is the origin explicit instead of *?
  5. Did the browser send a preflight OPTIONS request?
  6. Does the preflight response allow the method and headers being requested?
  7. Are needed response headers exposed with Access-Control-Expose-Headers?
  8. 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.