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.comhttps://api-myapp.onrender.comhttps://myapp.comhttps://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:
POSTwithapplication/json- custom
Authorizationheader
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.comhttps://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-OriginAccess-Control-Allow-MethodsAccess-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
allowedHeaderstight - 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.