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:

  1. No browser access to the webhook endpoint
  2. Browser calls a separate backend API
  3. Browser calls the same backend, but not the webhook route
  4. Browser calls a public proxy with permissive CORS
  5. 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 OPTIONS handling 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.


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.