I’ve seen the same Cloud Run rollout go sideways more than once: the service works in curl, works in Postman, even works from server-side code — then the browser blocks it and everyone blames Google.
Usually, Cloud Run is innocent. The app is returning the wrong CORS headers, returning them inconsistently, or forgetting that browsers send a completely separate preflight request before the “real” one.
Here’s a real-world style case study based on a pattern I’ve had to fix in production.
The setup
A team had this architecture:
- Frontend:
https://app.example.com - API on Cloud Run:
https://customer-api-abc123-uc.a.run.app
The frontend called the API directly from the browser using fetch().
Everything looked fine at first. A simple GET /profile worked in local testing. Then they shipped:
- authenticated requests using cookies
PATCHandDELETEendpointsAuthorizationheader for some token-based clients- custom header
X-Client-Version
That’s when the browser started throwing the classic CORS errors:
No 'Access-Control-Allow-Origin' header is presentResponse to preflight request doesn't pass access control checkThe value of the 'Access-Control-Allow-Credentials' header is '' which must be 'true'
The API itself was healthy. Cloud Run logs showed 200 and 204. The browser still blocked the frontend.
That difference matters. CORS failure usually means your backend responded, but the browser refused to hand the response to JavaScript.
What they had before
The team started with a very common Express app:
import express from "express";
const app = express();
app.use(express.json());
app.get("/profile", (req, res) => {
res.json({ id: 42, email: "[email protected]" });
});
app.patch("/profile", (req, res) => {
res.json({ ok: true });
});
const port = process.env.PORT || 8080;
app.listen(port, () => {
console.log(`Listening on ${port}`);
});
No CORS headers at all.
For curl, that’s fine:
curl https://customer-api-abc123-uc.a.run.app/profile
But from the browser:
fetch("https://customer-api-abc123-uc.a.run.app/profile", {
credentials: "include",
headers: {
"Authorization": "Bearer token",
"X-Client-Version": "web-42"
}
});
That request triggers a preflight because of:
Authorization- custom header
X-Client-Version - credentials
- non-simple methods like
PATCH
The browser first sends:
OPTIONS /profile HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: PATCH
Access-Control-Request-Headers: authorization,x-client-version,content-type
Their app had no OPTIONS handler, so Express returned a default 404. Cloud Run delivered that 404 just fine. Browser said no.
The first “fix” that still broke production
Someone added this:
app.use((req, res, next) => {
res.set("Access-Control-Allow-Origin", "*");
res.set("Access-Control-Allow-Headers", "*");
res.set("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS");
next();
});
This made a few test cases pass, but it still broke authenticated browser requests.
Why? Two big reasons.
1. * and credentials do not mix
If the frontend uses cookies or credentials: "include", you cannot send:
Access-Control-Allow-Origin: *
You must return the specific allowed origin:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
That’s a browser rule, not a Cloud Run rule.
2. Preflight still needs a proper response
Setting headers on normal routes is not enough if OPTIONS gets rejected or routed incorrectly. Preflight needs a clean success response, usually 204 No Content.
The production-safe version
Here’s the version that actually fixed the rollout.
Node.js / Express example for Cloud Run
import express from "express";
const app = express();
app.use(express.json());
const allowedOrigins = new Set([
"https://app.example.com",
"https://admin.example.com"
]);
function setCors(req, res) {
const origin = req.get("Origin");
if (origin && allowedOrigins.has(origin)) {
res.set("Access-Control-Allow-Origin", origin);
res.set("Vary", "Origin");
res.set("Access-Control-Allow-Credentials", "true");
}
res.set("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS");
res.set(
"Access-Control-Allow-Headers",
"Authorization,Content-Type,X-Client-Version"
);
res.set("Access-Control-Max-Age", "3600");
// Optional: expose response headers your frontend actually needs
res.set("Access-Control-Expose-Headers", "ETag,Location,Retry-After");
}
app.use((req, res, next) => {
setCors(req, res);
if (req.method === "OPTIONS") {
return res.status(204).end();
}
next();
});
app.get("/profile", (req, res) => {
res.set("ETag", `"profile-v7"`);
res.json({ id: 42, email: "[email protected]" });
});
app.patch("/profile", (req, res) => {
res.set("Location", "/profile");
res.json({ ok: true });
});
const port = process.env.PORT || 8080;
app.listen(port, () => {
console.log(`Listening on ${port}`);
});
This version does a few things right:
- reflects only trusted origins
- includes
Vary: Originso caches don’t poison responses across origins - handles preflight explicitly
- allows credentials safely
- exposes only the response headers the frontend needs
That last point gets missed a lot.
Why Access-Control-Expose-Headers matters
By default, browser JavaScript can only read a limited set of response headers. If your frontend needs ETag, Location, or Retry-After, you must expose them.
A real example from 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’s a good real-world reminder that CORS is not just about allowing the request. Sometimes the request succeeds, but your frontend still can’t read the headers it needs.
For example:
const res = await fetch("https://customer-api-abc123-uc.a.run.app/profile", {
credentials: "include"
});
console.log(res.headers.get("ETag"));
Without:
Access-Control-Expose-Headers: ETag
res.headers.get("ETag") will be null in browser code even though the header is present on the network response.
Before and after behavior
Before
Request:
await fetch("https://customer-api-abc123-uc.a.run.app/profile", {
method: "PATCH",
credentials: "include",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer token",
"X-Client-Version": "web-42"
},
body: JSON.stringify({ theme: "dark" })
});
Result in browser:
- preflight
OPTIONSreturns 404 or missing headers - browser blocks request
- frontend sees generic CORS error
- backend team says “but the service is up”
After
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,PATCH,DELETE,OPTIONS
Access-Control-Allow-Headers: Authorization,Content-Type,X-Client-Version
Access-Control-Max-Age: 3600
Vary: Origin
Actual response:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: ETag,Location,Retry-After
ETag: "profile-v7"
Vary: Origin
Content-Type: application/json
Result in browser:
- request succeeds
- cookies are sent
- frontend can read
ETag - no random environment-specific failures
Cloud Run-specific gotchas
Cloud Run doesn’t magically manage CORS for your app. It runs your container. Your app still has to produce correct headers.
A few gotchas I’ve seen:
1. Mixed domains between preview and production
Teams allow:
"https://app.example.com"
but forget preview environments like:
"https://staging.example.com"
"https://feature-123.example.dev"
Then CORS “randomly” fails only for QA.
Be explicit about which environments are allowed.
2. Setting CORS only on success responses
If your API sends CORS headers on 200 but not on 401, 403, or 500, the frontend gets terrible visibility. The browser may block access to the error response entirely.
I prefer applying CORS headers early, before auth and before route logic, exactly like the middleware above.
3. Forgetting Vary: Origin
If you reflect request origins dynamically and sit behind caches or CDNs, missing Vary: Origin can leak the wrong CORS policy to another caller.
That one causes weird “works for me, fails for someone else” bugs.
4. Using * for everything
Access-Control-Allow-Headers: * and Access-Control-Allow-Origin: * look convenient. They’re rarely what you want in a real app with authentication.
Public APIs can get away with permissive CORS more often. Authenticated browser apps usually should not.
A tighter pattern for public vs authenticated endpoints
I like splitting policy by route type.
Public read-only endpoint
Access-Control-Allow-Origin: *
Fine for public metadata or health-ish API responses that don’t use credentials.
Authenticated user endpoint
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
That’s the safer shape for session cookies or privileged responses.
What changed for the team
Once they moved from “spray some wildcard headers everywhere” to route-aware, origin-aware CORS handling, the support noise disappeared.
Their biggest win wasn’t just making the browser happy. It was making behavior predictable:
- preflight always handled
- auth flows worked consistently
- frontend could read headers it depended on
- staging and prod matched
That’s really the whole game with CORS on Cloud Run. Cloud Run is the easy part. The hard part is being precise.
If you want the official platform docs for deployment and runtime behavior, use the Google Cloud Run documentation. For the browser-side CORS model itself, the MDN and Fetch specs are useful references, but for implementation, I’d start by testing your actual OPTIONS and real browser requests end to end.
And if you’re already tightening response headers beyond CORS, that’s where a broader header policy starts to matter too. For that side of the stack, I’d look at https://csp-guide.com alongside your app’s own threat model.