Internal dashboards are where bad CORS habits go to hide.
I’ve seen teams lock down customer-facing APIs pretty well, then turn around and ship an admin panel that talks to five internal services with Access-Control-Allow-Origin: *, cookies flying around, and preflights failing randomly because nobody remembers which proxy strips which header.
Internal tooling feels “safe” because it lives behind SSO, VPN, or a corporate network. That mindset causes sloppy CORS configs. Browsers don’t care that your app is “internal.” They still enforce the same rules, and attackers love soft targets with elevated access.
Here are the mistakes I see most often with internal dashboards, and how I’d fix them.
Mistake 1: Treating “internal” as a security boundary
A dashboard at https://admin.company.internal calling APIs on https://api.company.internal is still cross-origin. If your users are authenticated in the browser, CORS becomes part of your attack surface.
The usual bad take is:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
That combo is invalid. Browsers reject it when credentials are involved. And if your team “fixes” it by dropping credentials support without realizing the frontend uses cookies, you get weird auth bugs that only happen in the browser.
Fix
Decide whether the dashboard uses:
- cookie-based auth
- bearer tokens in
Authorizationheaders
If you use cookies or HTTP auth, return a specific allowed origin, not *.
Access-Control-Allow-Origin: https://admin.company.internal
Access-Control-Allow-Credentials: true
Vary: Origin
Vary: Origin matters if you reflect origins dynamically and anything in front of your app caches responses.
If you use token auth and no credentials, * can be fine for some endpoints, but I still wouldn’t default to it on internal APIs. Tight allowlists age better.
Mistake 2: Forgetting preflight requests exist
This one burns teams constantly. The dashboard works in local testing with a simple GET, then fails in production because the real request includes:
AuthorizationContent-Type: application/jsonPATCH,PUT, orDELETE- custom headers like
X-Trace-Id
That triggers an OPTIONS preflight. If your API gateway, ingress, or app doesn’t answer it correctly, the real request never happens.
A common broken response looks like this:
HTTP/1.1 404 Not Found
or:
Access-Control-Allow-Origin: https://admin.company.internal
…with no Access-Control-Allow-Methods or Access-Control-Allow-Headers.
Fix
Handle OPTIONS explicitly at the edge or app layer.
Example in Express:
import express from "express";
import cors from "cors";
const app = express();
const allowedOrigins = new Set([
"https://admin.company.internal",
"https://ops.company.internal",
]);
app.use(cors({
origin(origin, cb) {
if (!origin) return cb(null, false); // non-browser or same-origin
if (allowedOrigins.has(origin)) return cb(null, origin);
return cb(new Error("Origin not allowed"));
},
credentials: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "X-Trace-Id"],
exposedHeaders: ["ETag", "X-Request-Id"],
maxAge: 600,
}));
app.options("*", cors());
And if you’re behind Nginx or a load balancer, make sure it doesn’t eat OPTIONS before your app sees it.
Mistake 3: Reflecting any Origin header
I still see middleware like this in internal tools:
res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
That’s not an allowlist. That’s “yes to whatever the browser asks for.”
If an employee with access to the dashboard visits a malicious site, that site can potentially make authenticated cross-origin requests if your cookies and CORS policy line up badly enough.
Fix
Validate the origin against an explicit allowlist.
const allowedOrigins = [
"https://admin.company.internal",
"https://support.company.internal",
];
const origin = req.headers.origin;
if (allowedOrigins.includes(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Vary", "Origin");
}
Be strict about exact scheme, host, and port. Don’t use suffix matching like:
origin.endsWith(".company.internal")
That sounds convenient until somebody introduces an unexpected subdomain, test environment, or a host you didn’t mean to trust.
Mistake 4: Missing Access-Control-Expose-Headers
Internal dashboards often need metadata from response headers:
- pagination links
- rate limit info
- request IDs
- ETags
- retry hints
The backend sends them, but frontend code can’t read them because browsers only expose a small safelist by default.
A good real-world example is GitHub’s API. 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 exactly how you support real clients. You expose what the frontend actually needs.
Fix
List the headers your dashboard reads.
Access-Control-Expose-Headers: ETag, Link, X-Request-Id, X-RateLimit-Remaining, Retry-After
Then your frontend can do:
const res = await fetch("https://api.company.internal/jobs", {
credentials: "include",
});
console.log(res.headers.get("X-Request-Id"));
console.log(res.headers.get("ETag"));
console.log(res.headers.get("Link"));
Without Access-Control-Expose-Headers, those reads usually come back null, which leads developers to blame fetch, proxies, browsers, and eventually the moon.
Mistake 5: Allowing too many methods and headers “just to make it work”
I get why this happens. Someone is blocked, so the API gets configured like this:
Access-Control-Allow-Methods: *
Access-Control-Allow-Headers: *
That’s lazy, and it hides what your frontend actually depends on. Internal tools change fast, but that’s not a reason to stop being intentional.
Fix
Start with the minimum set and expand when needed.
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization, X-Trace-Id
If your dashboard sends JSON and bearer tokens, that’s probably enough. If later you add If-Match for optimistic locking, add it deliberately.
This also makes debugging easier because you can compare the browser’s Access-Control-Request-Headers against what the server allows.
If you want a quick way to inspect live header behavior during debugging, HeaderTest is handy for checking what your endpoint actually returns after proxies and CDNs get involved.
Mistake 6: Ignoring CORS caching and Vary
Internal dashboards often sit behind layers of caching nobody fully understands: CDN, ingress cache, service mesh, reverse proxy. If your API returns different Access-Control-Allow-Origin values based on request origin and you don’t send Vary: Origin, caches can serve the wrong CORS headers to the wrong caller.
That creates maddening “works for me, fails for you” bugs.
Fix
When origin-specific responses are possible, send:
Vary: Origin
If preflight responses vary by requested method or headers, also consider:
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers
And set a sane preflight cache duration:
Access-Control-Max-Age: 600
Ten minutes is a reasonable starting point for internal tooling. Long enough to reduce noise, short enough that config changes don’t take forever to show up.
Mistake 7: Mixing CORS problems with CSRF problems
Teams blur these together all the time.
CORS controls whether browser JavaScript can read cross-origin responses. CSRF is about whether a browser can send authenticated requests at all.
If your internal dashboard uses cookies, CORS alone does not protect state-changing actions. A malicious site might still trigger requests even if it can’t read the response.
Fix
If you rely on cookies:
- use
SameSiteappropriately - require CSRF tokens for state-changing actions
- verify
OriginorRefereron sensitive endpoints
CORS is one layer, not the whole story. If you’re auditing the broader header posture of an internal dashboard, you’ll probably also want to look at CSP, X-Frame-Options or frame-ancestors, and friends. For that side of things, csp-guide.com is worth a look.
Mistake 8: Pushing CORS config into three places at once
I’ve seen CORS configured in:
- app code
- API gateway
- Nginx
That usually ends with duplicated or conflicting headers:
Access-Control-Allow-Origin: https://admin.company.internal, *
Browsers hate malformed CORS responses, and they fail hard.
Fix
Pick one layer to own CORS unless you have a very good reason not to.
My preference:
- gateway/edge owns simple, uniform CORS policy
- application owns it only when rules are endpoint-specific or dynamic
Then delete the duplicate config everywhere else.
While you’re at it, test the real deployed path, not just localhost. Internal tooling stacks are notorious for adding “helpful” middleware in staging and production that changes the behavior.
A sane baseline for internal dashboards
If you want a practical default for a cookie-authenticated internal dashboard, I’d start here:
Access-Control-Allow-Origin: https://admin.company.internal
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, X-Trace-Id, If-Match
Access-Control-Expose-Headers: ETag, Link, X-Request-Id, X-RateLimit-Remaining, Retry-After
Access-Control-Max-Age: 600
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers
Then tighten or expand based on what the frontend actually does.
That’s really the theme here: internal dashboards deserve the same discipline as public apps. Maybe more, because they usually expose more power. CORS doesn’t need to be fancy. It needs to be deliberate.