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.comhttps://api.example.com
  • https://app.example.comhttps://acme-api.herokuapp.com
  • http://localhost:3000http://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
  • Authorization with 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/json
  • Authorization header
  • 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.

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: Origin is present
  • OPTIONS returns 204 or 200, not 404
  • Access-Control-Allow-Credentials: true is only used when needed
  • Access-Control-Allow-Origin is 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.