Telegram bot webhooks and CORS are a weird combo, mostly because people often try to solve the wrong problem.
Here’s the blunt version: Telegram does not care about CORS when delivering webhooks to your server. Browsers care about CORS. Telegram is not a browser. So if your bot backend receives webhook requests from Telegram, CORS is irrelevant for that inbound traffic.
Where CORS does matter is when you put a browser app in front of your bot infrastructure and that browser tries to call your webhook endpoint, bot API proxy, status endpoint, or admin interface.
That distinction saves a lot of time.
The core comparison
If you’re building around Telegram bot webhooks, you usually end up choosing between these patterns:
- No browser access to the webhook endpoint
- Browser calls a separate backend API
- Browser calls the same backend, but not the webhook route
- Browser calls a public proxy with permissive CORS
- Browser calls Telegram directly
Only some of these are sane.
Option 1: No CORS on the webhook endpoint
This is my default recommendation.
Your webhook endpoint exists for Telegram servers only:
POST /telegram/webhook
Telegram sends updates to it. Your backend processes them. Browsers never touch it.
Pros
- Simplest model
- Best security posture
- No confusion between machine-to-machine traffic and browser traffic
- No need to maintain
OPTIONShandling for the webhook route - Less attack surface
Cons
- You can’t test the webhook route directly from browser frontend code
- If you built a single-page dashboard that wants update data, you need a separate API route
Example
import express from "express";
const app = express();
app.use(express.json());
app.post("/telegram/webhook", (req, res) => {
// Verify secret token if configured
const secret = req.get("X-Telegram-Bot-Api-Secret-Token");
if (secret !== process.env.TELEGRAM_WEBHOOK_SECRET) {
return res.status(403).send("forbidden");
}
const update = req.body;
console.log("Telegram update:", update);
res.sendStatus(200);
});
app.listen(3000);
No Access-Control-Allow-Origin. No browser support. That’s fine.
Option 2: Separate backend API for your frontend
This is the pattern I like most for real apps.
Your frontend calls routes like:
GET /api/bot/status
POST /api/bot/send-message
GET /api/bot/logs
Your webhook endpoint stays private-ish and purpose-built.
Pros
- Clean separation of concerns
- You can lock down CORS only where needed
- Easier auth design for frontend users
- Safer than exposing bot operations through the webhook route
- Easier to document and maintain
Cons
- More routes to build
- Slightly more infrastructure complexity
- You need to think about session auth or token auth for the frontend API
Example CORS config
import express from "express";
import cors from "cors";
const app = express();
app.use(express.json());
app.post("/telegram/webhook", (req, res) => {
res.sendStatus(200);
});
app.use("/api", cors({
origin: ["https://app.example.com"],
methods: ["GET", "POST"],
credentials: true,
}));
app.get("/api/bot/status", (req, res) => {
res.json({ ok: true, webhook: "active" });
});
app.post("/api/bot/send-message", async (req, res) => {
// call Telegram Bot API server-side
res.json({ sent: true });
});
app.listen(3000);
This is usually the right answer for dashboards, control panels, embedded web apps, and internal tooling.
Option 3: Same backend, different CORS behavior by route
This is a variation of option 2, but worth calling out because people often apply one global CORS policy and accidentally expose everything.
You can keep both webhook and browser-facing routes on the same host, while applying CORS selectively.
Pros
- Practical for small deployments
- One service, one domain, fewer moving pieces
- Lets you keep the webhook route non-CORS while exposing admin/API routes
Cons
- Easy to misconfigure
- Global middleware can accidentally add permissive CORS to webhook routes
- Harder to audit over time if route count grows
Example: selective route-level CORS
import express from "express";
import cors from "cors";
const app = express();
app.use(express.json());
const frontendCors = cors({
origin: "https://app.example.com",
methods: ["GET", "POST", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"],
credentials: true,
});
app.post("/telegram/webhook", (req, res) => {
// no CORS here
res.sendStatus(200);
});
app.options("/admin/*path", frontendCors);
app.use("/admin", frontendCors);
app.get("/admin/updates", (req, res) => {
res.json([]);
});
app.listen(3000);
If you choose this route, be disciplined. I’ve seen too many apps do app.use(cors()) at the top and forget about it.
Option 4: Permissive CORS with Access-Control-Allow-Origin: *
This is attractive because it makes browser errors disappear fast. It’s also where people get sloppy.
A real-world example: 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 works for GitHub because they’re exposing a public API designed for broad consumption. Your Telegram bot control API probably is not.
Pros
- Easy browser interoperability
- Great for intentionally public, read-only endpoints
- Minimal frontend friction
Cons
- Dangerous for bot management endpoints
- Encourages using browser clients where a backend should exist
- Can expose metadata and operational details to any origin
- Doesn’t mix with credentialed requests the way people expect
Good use case
A public status endpoint:
GET /api/public/bot-info
with:
app.get("/api/public/bot-info", (req, res) => {
res.set("Access-Control-Allow-Origin", "*");
res.json({
bot: "my_helper_bot",
status: "online"
});
});
Bad use case
Anything that sends messages, rotates webhook secrets, reads admin logs, or inspects user conversations.
If an endpoint changes state or reveals private data, I would not use wildcard CORS.
Option 5: Calling Telegram directly from the browser
I’ll say this plainly: don’t put your Telegram bot token in browser code.
Telegram Bot API calls require your bot token. If the browser has it, the token is effectively public. Game over.
Pros
- Fewer backend components
- Fast to prototype for five minutes
Cons
- Catastrophic secret exposure
- Anyone can reuse the token
- Your bot can be hijacked, spammed, or reconfigured
- No meaningful security boundary
This isn’t really a CORS decision. It’s a secret-management failure.
Use a backend. Always.
What CORS settings actually matter?
For Telegram webhook ecosystems, the useful headers are usually:
Access-Control-Allow-Origin
Use a specific origin when your frontend is known:
Access-Control-Allow-Origin: https://app.example.com
Use * only for truly public, non-sensitive resources.
Access-Control-Allow-Methods
Keep it narrow:
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers
Only include what your frontend needs:
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials
Use this only if you actually rely on cookies or browser credentials:
Access-Control-Allow-Credentials: true
If you set credentials to true, don’t use * for origin.
Access-Control-Expose-Headers
This one gets ignored a lot. If your frontend needs to read non-simple response headers, expose them explicitly.
GitHub does this well. Their access-control-expose-headers includes operationally useful headers like ETag, Link, and rate-limit metadata. That’s a solid example of a mature API exposing only what browser clients need.
For a Telegram bot admin API, maybe you expose something like:
Access-Control-Expose-Headers: X-Request-Id, X-RateLimit-Remaining
Not every header should be readable from browser JavaScript.
Recommended setups by use case
Personal bot with no frontend
- No CORS on webhook
- No browser access
- Backend only
Best choice.
Bot dashboard for your own domain
- Keep webhook route separate
- Allow CORS only for
https://app.example.com - Use session or token auth on admin/API routes
Best balance.
Public bot status page
- Public read-only endpoint
Access-Control-Allow-Origin: *is acceptable- No secrets, no state changes, no internal logs
Fine if you keep it boring.
Telegram Web App or embedded frontend
- Backend API required
- Tight origin allowlist
- Don’t expose bot token
- Separate webhook handling from browser-facing endpoints
This is where selective route CORS really pays off.
My opinionated rule set
If I’m reviewing a Telegram bot webhook deployment, here’s what I want to see:
- Webhook route has no browser-oriented CORS policy
- Frontend talks to separate API routes
- No wildcard CORS on anything sensitive
- No Telegram bot token in browser code
- Route-level CORS, not blanket
app.use(cors()) - Explicit exposure of response headers only when needed
And if you’re already thinking beyond CORS, check your other headers too. CORS is only one piece. Things like Content-Security-Policy, X-Frame-Options, and related browser protections matter for admin panels and frontend apps; if you want a focused guide on that side, see https://csp-guide.com.
For Telegram’s own webhook setup and bot behavior, stick to the official docs:
The short version: treat Telegram webhooks as server-to-server plumbing, not as browser APIs. Once you stop mixing those concerns, the CORS design gets a lot cleaner.