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
  • PUT and DELETE requests
  • 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:

  1. Access-Control-Allow-Origin: * was being used with credentialed requests.
  2. Nginx handled some OPTIONS requests directly, but didn’t return the right headers.
  3. 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 OPTIONS handling
  • exposedHeaders for 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 ETag and X-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:

  1. Keep CORS in one layer
    Prefer the app layer if origins or credentials vary.

  2. Don’t use * with credentials
    Browsers reject it. Every time.

  3. Handle preflight cleanly
    OPTIONS requests need the same policy logic.

  4. Return CORS headers on errors too
    Otherwise debugging turns into guesswork.

  5. Use Vary: Origin when origin is dynamic
    This matters for caches and CDNs.

  6. Expose only the headers your frontend needs
    Good candidates: ETag, Link, rate-limit headers, request IDs.

  7. 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.