Discord bot developers hit the same wall over and over: the bot works fine from Node.js, then somebody adds a web dashboard and the browser starts screaming about CORS.
I’ve seen this happen with moderation bots, music bots, internal community tools, and “quick” admin panels that turned into production apps. The pattern is predictable:
- the bot token works on the server
- somebody tries to call Discord directly from frontend JavaScript
- preflight requests fail, or worse, the token gets exposed
- the team starts sprinkling
Access-Control-Allow-Origin: *everywhere and hopes for the best
That’s not how you want to build a Discord bot dashboard.
The setup
A pretty standard architecture:
- Discord bot running on Node.js
- Express API for bot actions
- React dashboard for server admins
- dashboard lets admins:
- view guild settings
- trigger sync jobs
- list channels and roles
- manage welcome messages
The team wanted the frontend to “just call everything directly.” That included:
- their own backend at
https://api.bot.example - some Discord API endpoints
- a third-party metrics API
The result was a mess.
Before: direct browser calls and broken assumptions
Here’s the kind of frontend code I keep finding in real projects:
async function loadGuilds() {
const res = await fetch("https://discord.com/api/v10/users/@me/guilds", {
headers: {
Authorization: `Bot ${window.BOT_TOKEN}`
}
});
return res.json();
}
This is bad for two reasons.
First, bot tokens do not belong in the browser. Ever.
Second, even if CORS allowed this, you’d be giving every user a credential that can control your bot. That’s game over.
Sometimes the code is slightly less terrible, but still broken:
async function loadBotStats() {
const res = await fetch("https://api.bot.example/stats", {
method: "GET",
credentials: "include"
});
return res.json();
}
And the backend response headers look like this:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
That combination is invalid. Browsers reject it because Access-Control-Allow-Credentials: true cannot be used with *.
Then the team adds a custom auth header from the frontend:
fetch("https://api.bot.example/guilds/123/config", {
method: "PUT",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-Dashboard-Key": "abc123"
},
body: JSON.stringify({ prefix: "!" })
});
Now the browser sends a preflight request:
OPTIONS /guilds/123/config
Origin: https://dashboard.bot.example
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type,x-dashboard-key
And the server replies with something incomplete like:
Access-Control-Allow-Origin: https://dashboard.bot.example
Access-Control-Allow-Methods: GET, POST
No PUT. No Access-Control-Allow-Headers. So the browser blocks it before the real request is even sent.
That’s where a lot of Discord bot teams end up: the backend works in Postman, fails in the browser, and people blame React.
What actually needed to happen
The fix was architectural before it was technical:
- Keep Discord bot tokens server-side only
- Make the browser talk only to your backend
- Use CORS as a narrow browser access policy, not as an auth mechanism
- Return only the data the dashboard actually needs
The browser should never call Discord with bot credentials. Your backend should do that.
After: a clean proxy pattern
Here’s the safer version.
Frontend
async function loadGuilds() {
const res = await fetch("https://api.bot.example/api/guilds", {
credentials: "include"
});
if (!res.ok) {
throw new Error("Failed to load guilds");
}
return res.json();
}
Backend
import express from "express";
import cors from "cors";
const app = express();
const allowedOrigins = [
"https://dashboard.bot.example",
"https://staging-dashboard.bot.example"
];
app.use(cors({
origin(origin, callback) {
if (!origin) return callback(null, true); // non-browser tools
if (allowedOrigins.includes(origin)) {
return callback(null, true);
}
return callback(new Error("Not allowed by CORS"));
},
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"],
exposedHeaders: ["X-RateLimit-Remaining", "X-Request-Id"],
maxAge: 600
}));
app.get("/api/guilds", async (req, res) => {
const discordRes = await fetch("https://discord.com/api/v10/users/@me/guilds", {
headers: {
Authorization: `Bearer ${req.user.accessToken}`
}
});
const guilds = await discordRes.json();
res.set("X-Request-Id", crypto.randomUUID());
res.json(guilds);
});
This version does a few things right:
- allows only known dashboard origins
- supports cookies with
credentials: true - handles preflight properly
- exposes only selected response headers to browser JavaScript
- keeps secrets off the client
That last point matters more than anything else.
The subtle part: exposed headers
A lot of developers forget that JavaScript in the browser cannot read every response header by default.
That’s where Access-Control-Expose-Headers comes in.
A good real-world reference is GitHub’s API. api.github.com sends:
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 practical example of exposing headers that client apps genuinely need, especially for pagination and rate limit handling.
We borrowed that idea for the Discord bot dashboard. The frontend needed to read:
X-RateLimit-RemainingRetry-AfterX-Request-Id
So the backend explicitly exposed them:
app.use(cors({
origin: "https://dashboard.bot.example",
credentials: true,
exposedHeaders: ["X-RateLimit-Remaining", "Retry-After", "X-Request-Id"]
}));
Then the frontend could safely read them:
const res = await fetch("https://api.bot.example/api/guilds", {
credentials: "include"
});
console.log("Remaining:", res.headers.get("X-RateLimit-Remaining"));
console.log("Request ID:", res.headers.get("X-Request-Id"));
Without Access-Control-Expose-Headers, those values would be invisible to browser JavaScript even if they were present on the wire.
The preflight bug that kept breaking production
The nastiest issue in this case wasn’t GET. It was admin actions.
Example:
await fetch("https://api.bot.example/api/guilds/123/moderation", {
method: "PUT",
credentials: "include",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${sessionToken}`
},
body: JSON.stringify({
spamFilter: true,
logChannelId: "987654321"
})
});
Because this used:
PUTContent-Type: application/jsonAuthorization
…the browser sent a preflight request first.
The original Express app had no proper OPTIONS handling, and a CDN in front of it cached a bad preflight response. So some users saw random failures while others didn’t. Classic.
The fix was boring and effective:
app.options("*", cors());
And then making sure the actual CORS config matched real frontend behavior.
I always tell teams: if you haven’t tested the actual browser preflight, you haven’t tested CORS. A header inspection tool like HeaderTest is handy here because it shows exactly what your server is returning, which is often different from what you think your framework is returning.
What changed after the fix
Once the team moved to a backend-mediated model, a few things improved fast:
1. No secret leakage
The bot token and elevated credentials stayed on the server.
2. Cleaner permission boundaries
The frontend only saw data allowed for the authenticated admin. The backend enforced guild membership and role checks before making bot actions available.
3. Predictable browser behavior
Preflight requests were handled consistently. No more “works in curl, fails in Chrome.”
4. Better rate limit handling
By exposing rate-limit headers, the dashboard could back off gracefully instead of hammering endpoints.
The CORS policy we ended up with
For production, the final policy looked roughly like this:
const corsOptions = {
origin(origin, callback) {
const allowlist = new Set([
"https://dashboard.bot.example",
"https://staging-dashboard.bot.example"
]);
if (!origin || allowlist.has(origin)) {
callback(null, true);
} else {
callback(new Error("CORS blocked"));
}
},
credentials: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"],
exposedHeaders: ["X-RateLimit-Remaining", "Retry-After", "X-Request-Id"],
maxAge: 600
};
app.use(cors(corsOptions));
app.options("*", cors(corsOptions));
That’s not flashy. It’s just correct.
A couple of hard rules for Discord bot dashboards
If you’re building anything browser-based around a Discord bot, I’d treat these as non-negotiable:
Never put bot tokens in frontend code
Not in source, not in local storage, not in a hidden field, not anywhere.
Don’t use CORS as access control
CORS only tells browsers what cross-origin responses they can read. It does not stop server-to-server abuse, curl, or malicious scripts outside the browser.
Avoid Access-Control-Allow-Origin: * when using sessions or cookies
If your dashboard uses authentication with credentials, specify exact origins.
Expose only headers your frontend truly needs
GitHub’s API is a good model here: expose useful operational headers deliberately, not everything by accident.
Check your other headers too
Once a dashboard becomes real, CORS is only one piece. You’ll usually want CSP, X-Frame-Options or frame-ancestors, and other browser protections. If you’re tightening the full header set, csp-guide.com is worth keeping around.
Discord bot dashboards usually start as side projects. Then one day they’re handling moderation actions for thousands of users. That’s when sloppy CORS turns from annoying into dangerous.
The “after” version of this case study wasn’t clever. It just respected the browser, kept secrets on the server, and treated CORS like a precise compatibility layer instead of a magic security switch. That’s the version that survives production.