I’ve seen a lot of teams blame Heroku when their frontend suddenly starts throwing CORS errors after deployment.
Usually, Heroku is not the problem. Heroku just makes bad CORS assumptions painfully visible.
This case study comes from a very common setup:
- React frontend on one Heroku app or a custom domain
- Node/Express API on another Heroku app
- Everything works locally
- Production blows up with
No 'Access-Control-Allow-Origin' header is present
The painful part is that the app often looks fine in Postman, curl, or server-to-server tests. Then the browser blocks it anyway.
The setup
Here’s the real-world deployment shape:
- Frontend:
https://app.example.com - API on Heroku:
https://acme-api-9f3b2c1d4e.herokuapp.com - Later mapped to:
https://api.example.com
Locally, the team was using:
- Frontend:
http://localhost:3000 - API:
http://localhost:5000
Because local development used a proxy in the frontend dev server, nobody noticed the browser wasn’t really exercising production CORS rules.
Then deployment happened.
The symptom
From the browser console:
Access to fetch at 'https://acme-api-9f3b2c1d4e.herokuapp.com/v1/profile'
from origin 'https://app.example.com' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
The backend developer insisted the API was healthy because this worked:
curl https://acme-api-9f3b2c1d4e.herokuapp.com/v1/profile
And it did. That test just didn’t prove anything about browser access.
The original backend code
This was the pre-deploy Express app:
const express = require("express");
const app = express();
app.use(express.json());
app.get("/v1/profile", (req, res) => {
res.json({
id: 42,
name: "Ava",
plan: "pro"
});
});
const port = process.env.PORT || 5000;
app.listen(port, () => {
console.log(`API listening on ${port}`);
});
Nothing here is wrong for a plain API. It just doesn’t tell browsers which origins are allowed to read responses.
Why Heroku makes this show up
Heroku gives every app its own domain. The minute your frontend and backend live on different origins, the browser enforces CORS.
Different origin means any difference in:
- scheme
- host
- port
So these are all cross-origin:
https://app.example.com→https://api.example.comhttps://app.example.com→https://acme-api.herokuapp.comhttp://localhost:3000→http://localhost:5000
If you hid that behind a local proxy during development, production is your first real CORS test.
The first bad fix
The team added this:
app.use((req, res, next) => {
res.setHeader("Access-Control-Allow-Origin", "*");
next();
});
That made simple GET requests work.
Then login broke.
The frontend was sending cookies with:
fetch("https://api.example.com/v1/session", {
credentials: "include"
});
And now the browser complained:
The value of the 'Access-Control-Allow-Origin' header in the response
must not be the wildcard '*' when the request's credentials mode is 'include'.
This is one of the most common production mistakes I see.
If you use:
- cookies
- session auth
Authorizationwith strict browser credential handling- authenticated XHR/fetch patterns that require credentials
then Access-Control-Allow-Origin: * is the wrong answer.
For public APIs, wildcard can be fine. GitHub’s API is a good example of that pattern. Real headers from api.github.com include:
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 works because GitHub exposes a public API model. Most app backends on Heroku are not public APIs. They’re app-specific and authenticated.
The second bad fix
Then they tried to “support preflight” with this:
app.use((req, res, next) => {
res.setHeader("Access-Control-Allow-Origin", "https://app.example.com");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
next();
});
Still broken.
Why? Because the browser was sending an OPTIONS preflight request before the real request, and Express wasn’t returning a clean response for it.
The failing frontend request looked like this:
fetch("https://api.example.com/v1/session", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer token"
},
body: JSON.stringify({ email, password })
});
That triggers preflight because of:
Content-Type: application/jsonAuthorizationheader- credentialed cross-origin request
The before state: what the browser saw
Preflight request:
OPTIONS /v1/session HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization
Bad response:
HTTP/1.1 404 Not Found
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
That 404 is enough to kill the request in the browser.
This is the part people miss: the headers can look “mostly right” and still fail if preflight handling is sloppy.
The production-safe fix
Here’s the version that actually solved it:
const express = require("express");
const app = express();
const allowedOrigins = new Set([
"https://app.example.com",
"https://www.app.example.com",
"http://localhost:3000"
]);
app.use(express.json());
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, Location");
}
if (req.method === "OPTIONS") {
return res.sendStatus(204);
}
next();
});
app.post("/v1/session", (req, res) => {
res.cookie("sid", "abc123", {
httpOnly: true,
sameSite: "none",
secure: true
});
res.json({ ok: true });
});
app.get("/v1/profile", (req, res) => {
res.json({
id: 42,
name: "Ava",
plan: "pro"
});
});
const port = process.env.PORT || 5000;
app.listen(port, () => {
console.log(`API listening on ${port}`);
});
Why this fixed it
A few details matter here.
1. Reflecting the allowed origin
For credentialed requests, you need a specific allowed origin:
Access-Control-Allow-Origin: https://app.example.com
Not *.
2. Sending Vary: Origin
This matters more on Heroku than people expect, especially if you later put caching or a CDN in front of the app.
Without Vary: Origin, one origin’s response can get reused incorrectly for another.
3. Handling OPTIONS cleanly
Preflight should return fast and clean:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE,OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Vary: Origin
That’s what the browser wanted.
4. Matching cookie settings to cross-origin reality
A lot of “CORS bugs” are actually cookie policy bugs.
For cross-site cookies in modern browsers, this usually means:
sameSite: "none",
secure: true
If you skip that, auth can still fail even after CORS is technically correct.
The after state
Once fixed, the browser flow looked like this.
Preflight response:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE,OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Expose-Headers: ETag, Location
Vary: Origin
Actual response:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
Set-Cookie: sid=abc123; Path=/; HttpOnly; Secure; SameSite=None
Content-Type: application/json
And the frontend request finally worked:
const res = await fetch("https://api.example.com/v1/session", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ email, password })
});
const data = await res.json();
console.log(data);
A Heroku-specific lesson that keeps coming up
Teams often whitelist the wrong origin.
They allow:
https://acme-api-9f3b2c1d4e.herokuapp.com
when they should allow:
https://app.example.com
CORS checks the requesting frontend origin, not the API’s own hostname.
That sounds obvious when you say it out loud, but I still see it misconfigured all the time.
When * is actually fine
If your Heroku app is serving a public, unauthenticated API, wildcard can be perfectly reasonable.
That’s the GitHub-style pattern:
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 also shows another useful trick: if your frontend needs access to non-simple response headers like ETag, Link, or rate-limit headers, expose them explicitly with Access-Control-Expose-Headers.
My default Heroku CORS checklist
When I deploy a browser-facing API on Heroku, I check these first:
- Exact frontend origins are whitelisted
Vary: Originis presentOPTIONSreturns204or200, not404Access-Control-Allow-Credentials: trueis only used when neededAccess-Control-Allow-Originis never*with credentials- Cookie settings match cross-site usage
- Custom response headers are exposed if the frontend reads them
And if I’m reviewing the app’s broader header posture, I also check CSP, HSTS, and related policies. If you want a focused guide on that side of things, https://csp-guide.com is useful, but don’t confuse those headers with CORS. They solve different problems.
Heroku didn’t create the bug in this case. It just forced the app to behave like a real cross-origin deployment. That’s usually where the truth comes out.