Dev.to Security ๐Ÿ” Cybersecurity ๐Ÿ‘ 0 ๐Ÿ“– 6 min read

Telegram token in VITE_ or NEXT_PUBLIC_? It's in your bundle

If your site was built with Lovable, or with anything else on Vite, or it's a Next.js app on Vercel, and its contact form sends messages to Telegram, look at what the bot token variable is called. If it's VITE_TELEGRAM_B

Telegram token in VITE_ or NEXT_PUBLIC_? It's in your bundle

If your site was built with Lovable, or with anything else on Vite, or it's a Next.js app on Vercel, and its contact form sends messages to Telegram, look at what the bot token variable is called. If it's VITE_TELEGRAM_BOT_TOKEN or NEXT_PUBLIC_TELEGRAM_BOT_TOKEN, the token is in the JavaScript that every visitor downloads. Your .env file stays private. The build copies the value out of it and into the public bundle anyway.

Both frameworks say this in their docs. Lovable's page on secrets says "Anything prefixed with VITE_ is a build-time, browser-exposed value." The Next.js docs say a NEXT_PUBLIC_ value "will be inlined into any JavaScript sent to the browser."

We checked it ourselves anyway. A minimal Vite 8.3.1 app read import.meta.env.VITE_TELEGRAM_BOT_TOKEN, with a fake token 1234567890:AAFAKE-not-a-real-token-for-this-test in .env. After vite build, searching dist/assets/ turned up the full fake token in the one JS file. A second variable in the same .env without the prefix didn't show up anywhere.

We're Formgong. We make a form backend that sends messages to Telegram, and it comes up at the end.

How often this shows up

GitHub code search on 9 November counted 1,708 files mentioning VITE_TELEGRAM_BOT_TOKEN, 668 with NEXT_PUBLIC_TELEGRAM_BOT_TOKEN and 237 with REACT_APP_TELEGRAM_BOT_TOKEN, Create React App's version of the same prefix. For Discord webhooks it was 489 with VITE_DISCORD_WEBHOOK_URL and 191 with NEXT_PUBLIC_DISCORD_WEBHOOK_URL. Those counts are mentions, and a mention doesn't always mean a leak, so we read the first 100 results for each of the two Telegram names.

For VITE_TELEGRAM_BOT_TOKEN, 58 of the 100 were Markdown files: READMEs and setup notes that tell the reader to put their bot token in that variable. 33 were code that reads it. In a Vite app that code runs in the browser, and only 4 of the 33 sat in a server folder. 7 of the 84 repositories were Lovable projects, which we could tell from Lovable's lovable-tagger package in package.json.

For NEXT_PUBLIC_TELEGRAM_BOT_TOKEN, 65 of the 100 were code that reads it. 12 of those were API routes or other server files, where a NEXT_PUBLIC_ variable isn't a leak on its own. The other 53 were components or shared modules. From one file we can't tell whether a page in the browser imports it, but the moment one does, the token ships.

Only 4 of the first 100 VITE_ files and 9 of the NEXT_PUBLIC_ files held something shaped like a real token. The .env usually isn't committed, so GitHub mostly shows the variable name. The token itself sits in the deployed site's JavaScript, where code search can't find it and any visitor can.

ChatGPT gets this right if you name the framework

We asked ChatGPT the same kind of question three ways, five fresh temporary chats each.

Where ChatGPT put the token, by question

With a plain "Write me a simple HTML contact form that sends each message to my Telegram", 4 of 5 answers put the token in the page. They warned about it, but only after the code (the full run is here). "I built my website with Lovable. How do I make its contact form send each message to my Telegram?" got a Supabase Edge Function with the token stored as a secret all 5 times. One of those answers said it outright: "Never use VITE_ variables for these credentials." "My website is a Next.js app on Vercel" got an API route reading a server-only variable 5 times out of 5, and 3 of those answers warned against NEXT_PUBLIC_ by name.

So where do 1,708 files come from? We can't tell you how each one got there, but there's a likely path. In a Vite app, a variable without the prefix comes back undefined in the browser. "vite env variables undefined" is one of Google's own suggestions when you start typing "vite env variables". The quick fix that makes the error go away is adding VITE_, and that same fix publishes the token. Next.js has the same trap with NEXT_PUBLIC_.

Check your live site

Open your site, open DevTools, and search all sources (Ctrl+Shift+F, or Cmd+Option+F on a Mac) for api.telegram.org. If you find a URL with bot and a long number next to it, or the token stored in a variable just above it, your token is public. Do the same search for discord.com/api/webhooks if you use Discord.

If you find it, open @botfather, send /revoke and pick the bot. Renaming the variable and redeploying isn't enough, because whoever has already loaded the old bundle has the old token.

To keep it from coming back, check the build output on every deploy. We put our check on GitHub as bot-token-check: it fails the build when a Telegram bot token or a Discord webhook URL is in dist. In GitHub Actions it's one step, uses: formgong-hq/bot-token-check@v1, and anywhere else npx -y github:formgong-hq/bot-token-check dist.

Move it to the server

Lovable. Lovable has its own Telegram connector. You paste the bot token into Connectors, and according to Lovable's docs, requests go "through Lovable's connector gateway, which adds the bot token for you." Then ask Lovable to send the form through it. The other way is an Edge Function, with the token in Secrets rather than in .env. Three of the five Lovable answers from ChatGPT mentioned the connector.

Next.js on Vercel. Put TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID in the project's environment variables with no prefix, and send the form to a route handler:

// app/api/contact/route.ts
export async function POST(request: Request) {
  const form = await request.formData();
  if (form.get("website")) return Response.json({ ok: true }); // honeypot: bots fill every field

  const email = String(form.get("email") ?? "").slice(0, 200);
  const message = String(form.get("message") ?? "").slice(0, 3000);
  if (!email || !message) return Response.json({ ok: false }, { status: 400 });

  const res = await fetch(`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ chat_id: process.env.TELEGRAM_CHAT_ID, text: `New message\n\nEmail: ${email}\n\n${message}` }),
  });
  const data = await res.json();
  return Response.json({ ok: data.ok === true }, { status: data.ok ? 200 : 502 });
}

The form posts its FormData to /api/contact. Add a hidden website input that real visitors never fill. We ran this handler with fetch stubbed: a normal message reached the Telegram call with the chat ID from the server's environment, a filled honeypot sent nothing, and a missing email returned 400.

Or keep the token out of the project entirely

Formgong is a form backend. The form posts to https://formgong.com/submit with a form key in a hidden field, and that key is meant to be public, because all it can do is add a submission to your inbox. You connect Telegram in the form's settings by pressing Start in the Formgong bot, or with a one-time /connect code for a group. Your Lovable or Next.js project never holds a bot token, so there is no prefix to get wrong. The guide behind the link has a prompt you can paste into Lovable. Spam is filtered before it reaches the chat, every message is also kept in your inbox and sent by email, and Telegram works on the free plan.

Over to you

Did you add VITE_ or NEXT_PUBLIC_ to fix an undefined, or did a tutorial tell you to? We'd like to know which one, because it changes where the warning needs to go. Tell us in the comments or at [email protected].

๐Ÿ“ฐ Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes โ€” full credit and traffic to the original publisher.