CORS on Render usually fails for boring reasons: the wrong origin, missing preflight handling, or a preview URL you forgot to allow.

If you deploy APIs or frontends on Render, you’ll hit this fast. Your frontend lives at one origin, your API at another, and the browser blocks requests unless the API sends the right Access-Control-* headers.

Render itself doesn’t “do CORS” for your app. Your service has to return the headers. That’s the part many people miss.

What CORS actually means on Render

CORS is a browser enforcement layer. Render just hosts your services.

These are different origins:

  • https://myapp.onrender.com
  • https://api-myapp.onrender.com
  • https://myapp.com
  • https://staging-myapp.onrender.com

Even if they’re all yours, they’re still cross-origin if scheme, host, or port differs.

Typical Render setup:

  • Static Site frontend on https://frontend.onrender.com
  • Web Service API on https://api.onrender.com

A browser request from frontend to API needs the API to explicitly allow the frontend origin.

The minimum CORS headers you need

For a simple cross-origin GET, the API usually needs:

Access-Control-Allow-Origin: https://frontend.onrender.com

For credentialed requests with cookies or HTTP auth:

Access-Control-Allow-Origin: https://frontend.onrender.com
Access-Control-Allow-Credentials: true

For preflighted requests like POST with JSON or custom headers, you also need to answer OPTIONS requests:

Access-Control-Allow-Origin: https://frontend.onrender.com
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true

If you want frontend JavaScript to read non-simple response headers, add Access-Control-Expose-Headers.

GitHub’s API is a good real-world example. api.github.com returns:

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, Warning

That Expose-Headers part matters. Without it, your frontend can’t read those headers even though they’re present in the network response.

A common Render failure mode

You deploy your frontend and API separately:

  • Frontend: https://my-frontend.onrender.com
  • API: https://my-api.onrender.com

Frontend code:

fetch("https://my-api.onrender.com/users", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Authorization": `Bearer ${token}`
  },
  body: JSON.stringify({ name: "Ava" })
});

This triggers a preflight because:

  • POST with application/json
  • custom Authorization header

Browser sends:

OPTIONS /users
Origin: https://my-frontend.onrender.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization

If your API doesn’t handle OPTIONS correctly, the browser blocks the actual POST before it even happens.

That’s why “it works in Postman” is meaningless here. Postman does not enforce browser CORS.

Express on Render: solid CORS setup

Here’s a production-friendly Express setup without guessing.

Using the cors middleware

import express from "express";
import cors from "cors";

const app = express();

const allowedOrigins = [
  "https://my-frontend.onrender.com",
  "https://www.myapp.com",
  "https://myapp.com"
];

app.use(cors({
  origin(origin, callback) {
    // allow non-browser tools or same-origin requests with no Origin header
    if (!origin) return callback(null, true);

    if (allowedOrigins.includes(origin)) {
      return callback(null, true);
    }

    return callback(new Error(`CORS blocked for origin: ${origin}`));
  },
  methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
  allowedHeaders: ["Content-Type", "Authorization"],
  exposedHeaders: ["ETag", "Link", "Retry-After", "X-Request-Id"],
  credentials: true,
  maxAge: 86400
}));

app.options("*", cors());

app.use(express.json());

app.get("/health", (req, res) => {
  res.json({ ok: true });
});

app.post("/users", (req, res) => {
  res.status(201).json({ user: req.body });
});

const port = process.env.PORT || 3000;
app.listen(port, () => {
  console.log(`Listening on ${port}`);
});

Why I like this setup

  • explicit allowlist
  • works with cookies if you need them
  • handles preflight
  • exposes useful response headers
  • doesn’t pretend * is okay for authenticated apps

Don’t use * with credentials

This is one of the most common CORS mistakes.

This is invalid for credentialed requests:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

If you send cookies, session auth, or credentials: "include" from fetch, use a specific origin.

Frontend example:

fetch("https://my-api.onrender.com/session", {
  credentials: "include"
});

API must respond with:

Access-Control-Allow-Origin: https://my-frontend.onrender.com
Access-Control-Allow-Credentials: true
Vary: Origin

Vary: Origin helps caches avoid serving the wrong CORS response to another origin. Good middleware usually handles this, but I still check for it.

Manual CORS handling in Express

Sometimes I skip middleware for smaller services or when I want total control.

import express from "express";

const app = express();
const allowedOrigins = new Set([
  "https://my-frontend.onrender.com",
  "https://myapp.com"
]);

app.use((req, res, next) => {
  const origin = req.headers.origin;

  if (origin && allowedOrigins.has(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Vary", "Origin");
    res.setHeader("Access-Control-Allow-Credentials", "true");
    res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
    res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
    res.setHeader("Access-Control-Expose-Headers", "ETag, Link, Retry-After, X-Request-Id");
  }

  if (req.method === "OPTIONS") {
    return res.sendStatus(204);
  }

  next();
});

app.use(express.json());

app.get("/repos", (req, res) => {
  res.setHeader("ETag", '"abc123"');
  res.setHeader("X-Request-Id", "req_123");
  res.json([{ id: 1, name: "demo" }]);
});

app.listen(process.env.PORT || 3000);

This works well on Render because nothing about the platform gets in your way. Your app owns the headers.

Flask on Render

If your Render API is Python, same rules.

With flask-cors

from flask import Flask, jsonify, request
from flask_cors import CORS

app = Flask(__name__)

CORS(
    app,
    origins=[
        "https://my-frontend.onrender.com",
        "https://myapp.com",
    ],
    methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
    allow_headers=["Content-Type", "Authorization"],
    expose_headers=["ETag", "Link", "Retry-After", "X-Request-Id"],
    supports_credentials=True,
    max_age=86400,
)

@app.route("/users", methods=["POST"])
def create_user():
    return jsonify(request.json), 201

@app.route("/health", methods=["GET"])
def health():
    return jsonify({"ok": True})

if __name__ == "__main__":
    app.run()

Render preview environments and branch deploys

This is where clean CORS configs go to die.

If Render creates preview URLs per branch, hardcoding one frontend origin won’t survive long. You have a few choices:

1. Explicit allowlist from env var

I prefer this for internal teams.

const allowedOrigins = (process.env.ALLOWED_ORIGINS || "")
  .split(",")
  .map(s => s.trim())
  .filter(Boolean);

Render environment variable:

ALLOWED_ORIGINS=https://my-frontend.onrender.com,https://staging-my-frontend.onrender.com,https://myapp.com

2. Regex or suffix matching for preview domains

Useful, but be careful.

function isAllowedOrigin(origin) {
  if (!origin) return true;

  const exact = [
    "https://myapp.com",
    "https://www.myapp.com"
  ];

  if (exact.includes(origin)) return true;

  return /^https:\/\/.+-my-frontend\.onrender\.com$/.test(origin);
}

This can support preview deploys like:

  • https://feature-login-my-frontend.onrender.com
  • https://fix-header-my-frontend.onrender.com

Be strict. Don’t match all onrender.com origins unless you want random apps talking to your API.

Static Site rewrites can avoid some CORS pain

If your frontend and API can live under the same site origin via rewrites or reverse proxying, do that. Same-origin architecture avoids a lot of CORS complexity.

For example, if users call /api/users on https://myapp.com and your platform proxies it to the backend, the browser sees same-origin.

When that’s not possible on your setup, configure CORS properly and move on.

Reading exposed headers in the browser

If your API returns pagination or rate-limit info in headers, expose them.

Server:

Access-Control-Expose-Headers: ETag, Link, X-RateLimit-Remaining
Link: <https://api.example.com/users?page=2>; rel="next"
X-RateLimit-Remaining: 57
ETag: "users-v1"

Browser:

const res = await fetch("https://my-api.onrender.com/users");

console.log(res.headers.get("ETag"));
console.log(res.headers.get("Link"));
console.log(res.headers.get("X-RateLimit-Remaining"));

Without Access-Control-Expose-Headers, those calls usually return null for non-simple headers.

GitHub exposes a long list for exactly this reason.

Debugging CORS on Render without guessing

When I debug CORS, I check these in order:

1. Confirm the exact Origin

Open DevTools and inspect the request headers.

You need the literal origin, like:

Origin: https://my-frontend.onrender.com

Not the API URL. Not the route. The frontend origin.

2. Check the preflight response

For OPTIONS, verify:

  • Access-Control-Allow-Origin
  • Access-Control-Allow-Methods
  • Access-Control-Allow-Headers

And make sure the status is something sane like 200 or 204.

3. Check credentials mode

If frontend uses:

credentials: "include"

then the API cannot use *.

4. Check if a redirect is happening

A 301 or 302 during preflight or API request often causes confusing failures.

This bites people when they call http:// and get redirected to https://, or hit the wrong custom domain.

5. Check if your app code runs before headers are set

Auth middleware sometimes rejects requests before CORS middleware runs. Then the browser sees a CORS failure instead of the real 401.

Put CORS early in the middleware chain.

Security advice I actually follow

My defaults:

  • use exact origins in production
  • use * only for public unauthenticated APIs
  • expose only headers the frontend needs
  • keep allowedHeaders tight
  • answer preflight fast
  • avoid origin reflection unless it’s validated

If you’re also tightening other response headers, see official docs for your framework and platform. For broader header policy work beyond CORS, https://csp-guide.com is worth keeping around.

A safe production pattern for Render

If you want one pattern that covers most deployments, use this:

  • frontend origin in env var
  • exact allowlist
  • credentials only if needed
  • preflight support
  • explicit exposed headers

Example env-driven Express config:

import express from "express";
import cors from "cors";

const app = express();

const allowedOrigins = new Set(
  (process.env.ALLOWED_ORIGINS || "")
    .split(",")
    .map(v => v.trim())
    .filter(Boolean)
);

app.use(cors({
  origin(origin, callback) {
    if (!origin) return callback(null, true);
    if (allowedOrigins.has(origin)) return callback(null, true);
    return callback(new Error("Origin not allowed by CORS"));
  },
  credentials: true,
  methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
  allowedHeaders: ["Content-Type", "Authorization"],
  exposedHeaders: ["ETag", "Link", "Retry-After", "X-Request-Id"],
  maxAge: 86400
}));

app.options("*", cors());
app.use(express.json());

app.get("/me", (req, res) => {
  res.set("X-Request-Id", "req_abc123");
  res.json({ id: 42, name: "Sam" });
});

app.listen(process.env.PORT || 3000);

Render won’t magically fix CORS, but it also won’t stop you from doing it correctly. Once you treat CORS as an app-level contract between frontend origin and API origin, the errors get a lot less mysterious.