Перейти к содержимому

Руководство

Ваш API-ключ LLM, скорее всего, лежит во фронтенд-бандле

Почему переменные VITE_ и NEXT_PUBLIC_ попадают в браузер, как проверить сборку на утёкший ключ OpenAI или Anthropic и какой серверный маршрут это исправляет.

· 7 мин чтения · Команда vetkit

Большой ключ выставлен внутри стеклянного окна браузера; робот, ничего не подозревая, полирует стекло, разработчик в тревоге указывает на ключ, а к нему тянется рука в перчатке.Большой ключ выставлен внутри стеклянного окна браузера; робот, ничего не подозревая, полирует стекло, разработчик в тревоге указывает на ключ, а к нему тянется рука в перчатке.

Вы просите ассистента добавить чат в ваше React-приложение. Он устанавливает OpenAI SDK, читает ключ из import.meta.env.VITE_OPENAI_API_KEY, и функция работает с первой попытки. Проблема в том, что она работает и для любого, кто откроет инструменты разработчика в браузере, найдёт ключ в вашем JavaScript и начнёт отправлять запросы за ваш счёт.

Это одна из самых частых ошибок, которые мы видим в проектах, написанных с помощью ИИ, и её легко пропустить на ревью, потому что ничего не выглядит неправильным. Ключ не захардкожен. Он лежит в файле .env, который корректно игнорируется git. Код читает его из переменной окружения — именно так, как всем советуют.

Как это происходит

У фронтенд-инструментов сборки нет переменных окружения во время выполнения. Браузер исполняет статический бандл. Поэтому, когда ваш код читает import.meta.env.VITE_SOMETHING или process.env.NEXT_PUBLIC_SOMETHING, бандлер во время сборки подставляет вместо этого выражения буквальное значение. После сборки никакой переменной уже нет — есть лишь строка в JavaScript-файле, который скачивает каждый посетитель.

Фреймворки говорят об этом прямо. В Next.js сказано, что публичные переменные встраиваются в любой JavaScript, отправляемый в браузер. Vite открывает клиентскому коду только переменные с префиксом VITE_, именно чтобы вы случайно не слили остальные. Префикс — это явное согласие. Когда ассистент выбирает VITE_OPENAI_API_KEY, потому что имя без префикса не работало в браузере, он дал это согласие за вас.

Есть и более незаметный вариант. Некоторые стартовые шаблоны копируют ключ в бандл через опцию Vite define, например 'process.env.GEMINI_API_KEY': JSON.stringify(...) в vite.config.ts. У переменной нет публичного префикса, поэтому она выглядит безопасной, но эффект ровно тот же.

Официальные SDK пытаются это предотвратить. OpenAI Node SDK отказывается работать в браузере, если не передать dangerouslyAllowBrowser: true, и его README объясняет почему: это раскрывает ваши секретные учётные данные в клиентском коде. Ассистенты иногда добавляют этот флаг, чтобы избавиться от ошибки. Другие клиенты, например обычные HTTP-вызовы или SDK без проверки на браузер, вообще не предупреждают.

Какие переменные публичны

Если переменная начинается с одного из этих префиксов, считайте, что её значение может прочитать любой, кто загрузит ваше приложение:

  • VITE_ в Vite (и во фреймворках на его основе)
  • NEXT_PUBLIC_ в Next.js
  • REACT_APP_ в Create React App
  • EXPO_PUBLIC_ в Expo и созданных с ним приложениях React Native
  • VUE_APP_ в Vue CLI и GATSBY_ в Gatsby
  • PUBLIC_ в Astro и в публичных env-модулях SvelteKit

Эти префиксы подходят для значений, которые и должны быть публичными, например ID сайта в аналитике или URL вашего собственного API. Для секрета провайдера они не подходят никогда: OpenAI, Anthropic, Google Gemini, Mistral, секретный ключ Stripe, service role key базы данных.

Проверьте свою сборку

Самый быстрый тест — собрать приложение и поискать в результате префикс ключа. Ключи проектов OpenAI начинаются с sk-proj-, ключи Anthropic — с sk-ant-, а ключи Google API — с AIza.

bash
npm run build# Vite writes to dist/, Next.js static chunks live in .next/static/grep -rEo "sk-(proj|ant)-[A-Za-z0-9_-]{8}|AIza[0-9A-Za-z_-]{8}" dist .next/static 2>/dev/null

Если команда что-то вывела, ключ в вашем бандле. Развёрнутый сайт проверяется так же: откройте инструменты разработчика, перейдите на вкладку Sources или Network и поищите в загруженных скриптах. Если вы можете найти ключ за тридцать секунд, то сможет и скрипт, который обходит сайты в поисках именно этого.

Как исправить

Перенесите вызов провайдера на сервер, который вы контролируете, и пусть браузер обращается к этому серверу. В Next.js это route handler. В одностраничном приложении на Vite это может быть serverless-функция на том же хостинге. Ключ сохраняет имя без префикса, поэтому бандлер никогда его не встраивает.

app/api/chat/route.ts
import OpenAI from 'openai'; // Server-only: no NEXT_PUBLIC_ prefix, never shipped to the browser.const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); export async function POST(request: Request) {  const user = await requireSignedInUser(request); // your auth check  await rateLimit(user.id); // your limiter: per user, per minute   const { message } = await request.json();  if (typeof message !== 'string' || message.length > 4000) {    return Response.json({ error: 'Invalid message' }, { status: 400 });  }   const response = await client.responses.create({    model: 'gpt-5-mini',    input: message,    max_output_tokens: 800,  });  return Response.json({ reply: response.output_text });}

Три детали вокруг вызова важны не меньше, чем сам перенос. Без проверки аутентификации ваш маршрут становится бесплатным анонимным прокси к модели — это та же утечка, только в несколько шагов. Без rate limit один авторизованный пользователь всё равно может накрутить большой счёт. Без ограничения вывода один запрос может сгенерировать гораздо больше токенов, чем нужно вашей функции.

Название модели и лимиты тоже держите на сервере

Если браузер отправляет название модели или max_output_tokens, посетитель может их изменить. Определяйте эти значения на сервере и принимайте от клиента только сообщение пользователя.

Если ключ уже ушёл в релиз

Само по себе удаление переменной из кода не поможет. Старые сборки остаются в кеше браузеров и CDN, а ключ, возможно, уже скопировали. Считайте его скомпрометированным:

  1. Создайте новый ключ в панели провайдера и отзовите старый.
  2. Положите новый ключ только в серверные настройки окружения под именем без публичного префикса.
  3. Проверьте на странице использования у провайдера незнакомый трафик и установите лимит расходов.
  4. Задеплойте заново, затем повторите описанный выше поиск по сборке, чтобы убедиться, что новый бандл чист.

Если ключ хоть раз попадал в коммит git, ротируйте его, даже если вы удалили файл. История git хранит старое значение.

Ловите это до релиза

Эту ошибку легко отсечь автоматически. Сканер секретов ловит ключи, закоммиченные в репозиторий. Но он не видит ключ, который лежит в игнорируемом файле .env, поэтому нужна ещё и проверка, которая читает код и замечает, что публичная переменная передаётся в LLM-клиент.

Этот второй паттерн — одно из правил безопасности LLM и агентов в полных сканированиях vetkit: оно помечает значение из клиентской переменной окружения (например, import.meta.env.VITE_* или process.env.NEXT_PUBLIC_*), которое попадает в клиент LLM SDK в браузерном коде, и объясняет, как перенести вызов на сервер. Бесплатный Security Quick Scan от vetkit охватывает закоммиченные секреты, известные уязвимые зависимости и базовую конфигурацию безопасности. Ни то, ни другое не заменяет быстрого взгляда на результат вашей собственной сборки — это самое прямое доказательство, какое только можно получить.

Запросить ранний доступ

Расскажите немного о своей команде и репозиториях, которые хотите проверять. Мы подключаем команды небольшими группами и ответим по электронной почте.

Мы используем эти данные только для ответа на ваш запрос. Политика конфиденциальности