A few months ago, I helped clean up a CORS mess on a small API running on Linode Akamai Compute. Nothing exotic: one frontend app, one backend API, both deployed fast, both working fine in local dev, and both breaking the minute a real browser got involved.
That’s the pattern with CORS. Curl works. Postman works. Backend logs look healthy. Then the browser says no.
This case study is for the setup I see all the time on Linode Akamai Compute:
- frontend on
app.example.com - API on a Compute instance at
api.example.com - Node.js API behind Nginx
- a mix of public endpoints and authenticated endpoints
The bug report sounded simple:
“The dashboard can load public data, but login and account requests fail randomly with CORS errors.”
“Randomly” usually means the server is inconsistent. And that’s exactly what was happening.
The setup that caused the problem
The team had deployed a Node API on a Linode instance and put Nginx in front of it for TLS termination and basic proxying.
Their Express app had this:
app.use((req, res, next) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
next();
});
Looks familiar, right? I’ve seen this in production more times than I’d like.
It worked for simple public requests. But their frontend also used:
Authorization: Bearer ...credentials: "include"for some cookie-based flows- custom headers for tracing
PUTandDELETErequests- browser fetches from a different subdomain
That turned the browser into the strict adult in the room.
What users actually saw
In DevTools, the frontend threw errors like:
Access to fetch at 'https://api.example.com/user/profile' from origin
'https://app.example.com' has been blocked by CORS policy:
The value of the 'Access-Control-Allow-Origin' header in the response
must not be '*' when the request's credentials mode is 'include'.
And on other requests:
Response to preflight request doesn't pass access control check:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
That second one is the giveaway. Usually it means the app handles normal requests one way, but preflight OPTIONS requests take a different path and miss the headers entirely.
The real issue
They had three separate CORS problems:
Access-Control-Allow-Origin: *was being used with credentialed requests.- Nginx handled some
OPTIONSrequests directly, but didn’t return the right headers. - Error responses from the API didn’t include CORS headers, so the browser hid the real 401/403/500 response.
That last one wastes hours. The API is actually replying, but the browser blocks access to the response because the CORS headers are missing on the error path.
Before: broken production config
Here’s a simplified version of what they had in Nginx:
server {
listen 443 ssl;
server_name api.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
}
location /api/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
return 204;
}
proxy_pass http://127.0.0.1:3000;
}
}
And Express added its own CORS headers too.
That meant:
- some responses got headers from Express
- some got headers from Nginx
- some got both
- some error responses got neither
- wildcard origin conflicted with credentials
Classic split-brain config.
How I debugged it
First, I checked the actual response headers in the browser and with curl. Then I used HeaderTest to quickly verify what the API returned across normal and preflight requests. That’s the fastest way I know to catch inconsistent header behavior between success and failure paths.
A preflight request looked like this:
curl -i -X OPTIONS https://api.example.com/user/profile \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: GET" \
-H "Access-Control-Request-Headers: authorization, content-type"
The response was missing Access-Control-Allow-Origin on some routes and returning * on others.
That’s enough to break the browser.
The fix: pick one layer and do CORS there
My rule is simple: do CORS in one place.
If the app needs dynamic origin validation, authenticated flows, route-specific behavior, or environment-aware config, I do it in the application. Nginx can still handle TLS and proxying, but not CORS logic.
For this API, Express was the right place.
After: working Express config
Here’s the version we shipped:
const express = require("express");
const cors = require("cors");
const app = express();
const allowedOrigins = new Set([
"https://app.example.com",
"https://admin.example.com",
]);
const corsOptions = {
origin(origin, callback) {
// Allow server-to-server requests or curl with no Origin header
if (!origin) return callback(null, true);
if (allowedOrigins.has(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", "X-Request-ID"],
credentials: true,
exposedHeaders: ["ETag", "Link", "X-Request-ID"],
maxAge: 86400,
};
app.use(cors(corsOptions));
app.options("*", cors(corsOptions));
app.use(express.json());
app.get("/public/status", (req, res) => {
res.json({ ok: true });
});
app.get("/user/profile", (req, res) => {
res.setHeader("X-Request-ID", "abc123");
res.json({ user: "demo" });
});
// Error handler that still preserves CORS behavior
app.use((err, req, res, next) => {
console.error(err.message);
res.status(500).json({ error: "Internal server error" });
});
app.listen(3000);
The big changes:
- no wildcard origin
- explicit allowlist
credentials: true- proper
OPTIONShandling exposedHeadersfor headers the frontend actually needed
Nginx after cleanup
Nginx got much simpler:
server {
listen 443 ssl;
server_name api.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
No duplicate CORS logic. No if ($request_method = OPTIONS) hacks.
Good.
Before and after behavior
Before
Preflight response:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Actual authenticated request:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
Set-Cookie: session=abc...
That breaks because cookies or credentialed requests cannot use * for Access-Control-Allow-Origin.
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,PUT,PATCH,DELETE,OPTIONS
Access-Control-Allow-Headers: Content-Type,Authorization,X-Request-ID
Access-Control-Max-Age: 86400
Vary: Origin
Actual request:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: ETag,Link,X-Request-ID
Vary: Origin
X-Request-ID: abc123
That’s what the browser wants.
A useful real-world reference: GitHub’s API
When I need to explain Access-Control-Expose-Headers, I often point to real APIs instead of toy examples.
api.github.com returns:
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
access-control-allow-origin: *
That makes sense for a broadly consumable public API. They expose useful metadata headers so browser clients can read rate-limit and pagination info.
If your frontend needs ETag, Link, or request IDs, you need to expose them too. Without Access-Control-Expose-Headers, the browser hides them from JavaScript even though they’re plainly visible in the network tab.
What changed for the team
Once we fixed the CORS setup:
- login requests stopped failing
- preflight requests became predictable
- frontend code could read
ETagandX-Request-ID - support tickets about “random API failures” disappeared
The nice part was that nothing about Linode Akamai Compute made this harder. The platform was fine. The issue was the usual one: CORS policy spread across too many layers.
What I’d do on every Linode deployment
If you’re deploying APIs on Linode Akamai Compute, this is the checklist I’d use every time:
-
Keep CORS in one layer
Prefer the app layer if origins or credentials vary. -
Don’t use
*with credentials
Browsers reject it. Every time. -
Handle preflight cleanly
OPTIONSrequests need the same policy logic. -
Return CORS headers on errors too
Otherwise debugging turns into guesswork. -
Use
Vary: Originwhen origin is dynamic
This matters for caches and CDNs. -
Expose only the headers your frontend needs
Good candidates:ETag,Link, rate-limit headers, request IDs. -
Test with the browser, not just curl
Curl is useful, but it does not enforce CORS.
If you’re also tightening the rest of your response headers, CSP, HSTS, and friends are a separate job from CORS. For that side of things, I’d point people to csp-guide.com.
The main lesson from this case was boring but true: CORS problems usually aren’t “browser weirdness.” They’re configuration drift. One wildcard here, one missing preflight there, one proxy trying to be helpful, and suddenly your API only works when nobody uses it the way browsers actually do.