Свой прокси к нейросети на Node.js: не показывать API-ключ во фронтенде

Небольшой сервер на Express между вашим фронтендом и API нейросети: ключ остаётся на сервере, а модели, длина запроса и частота ограничены.

4 мин · · разработка · API-ключи

Если вызвать API нейросети прямо из браузера, ключ окажется в коде страницы или во вкладке Network, и любой посетитель сможет его скопировать. Решение стандартное: поставить между фронтендом и API свой серверный слой. Ниже небольшой Node.js OpenAI proxy на Express: ключ лежит в переменной окружения, список моделей и длина запроса ограничены, ответ идёт потоком, а частота запросов считается по пользователю. Тот же приём работает и для моделей Gemini (например, gemini-3.8-flash): меняется только строка с моделью.

Зачем нужен прокси

Ключ во фронтенде нельзя спрятать: минификация и обфускация не помогают, ведь запрос к API всё равно видно в браузере. Серверный прокси решает сразу четыре задачи:

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

Общие правила хранения ключей собраны в статье про безопасность API-ключей.

Подготовка

bash
mkdir ai-proxy && cd ai-proxy
npm init -y
npm install express openai

В package.json добавьте "type": "module". Нужен Node.js 18 или новее. Создайте отдельный ключ под этот проект в кабинете Zukko и сразу задайте ему дневной лимит в долларах и список разрешённых моделей: это последняя линия защиты, если что-то пойдёт не так в коде. Как это сделать, описано в статье про лимит на ключ.

bash
export ZUKKO_API_KEY="fetch_ключ_этого_проекта"

Код сервера

bash
// server.js
import express from "express";
import OpenAI from "openai";

const client = new OpenAI({
  baseURL: "https://api.zukko.pro/v1",
  apiKey: process.env.ZUKKO_API_KEY,
});

const ALLOWED_MODELS = new Set(["deepseek-v4.1-flash", "claude-sonnet-5-5"]);
const MAX_MESSAGES = 20;
const MAX_CHARS = 8000;
const MAX_OUTPUT_TOKENS = 800;
const WINDOW_MS = 60_000;

Обратите внимание на три детали. Во-первых, роль system приходит только с сервера, а от клиента принимаются лишь user и assistant: иначе посетитель подставит свой системный промпт. Во-вторых, chunk.choices?.[0] с проверкой нужна потому, что последний чанк потока приходит с пустым списком choices. В-третьих, при закрытии соединения клиентом запрос к API отменяется, и генерация не продолжается впустую; токены, сгенерированные до обрыва, всё равно могут быть списаны.

Как читать поток во фронтенде

EventSource не умеет отправлять POST, поэтому используйте fetch и читайте тело как поток:

bash
const resp = await fetch("/api/chat", {
  method: "POST",
  headers: { "Content-Type": "application/json", Authorization: "Bearer " + userToken },
  body: JSON.stringify({ model: "deepseek-v4.1-flash", messages }),
});
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  buffer += decoder.decode(value, { stream: true });
  const events = buffer.split("\n\n");
  buffer = events.pop();
  for (const ev of events) {
    const data = ev.replace(/^data: /, "");
    if (data === "[DONE]") continue;
    const { delta } = JSON.parse(data);
    if (delta) output.textContent += delta;
  }
}

Для Next.js то же самое делается в Route Handler. Если нужен разбор стриминга на Express подробнее, он есть в статье про стриминг в Express.

Сколько может стоить злоупотребление

Посчитаем худший случай при ограничениях из кода. Лимит в коде задан в символах, поэтому возьмём с запасом (условно) 8000 входных токенов и 800 выходных. Цены на 18 октября 2026, $ за 1M токенов.

  • deepseek-v4.1-flash (0.013 / 0.038): 8000 × 0.013 / 1M + 800 × 0.038 / 1M = 0.000104 + 0.0000304 = около $0.00013.
  • claude-sonnet-5-5 (0.4 / 2): 8000 × 0.4 / 1M + 800 × 2 / 1M = 0.0032 + 0.0016 = $0.0048.

Лимит 10 запросов в минуту даёт до 600 запросов в час на одного пользователя. На Sonnet это 600 × 0.0048 = $2.88 в час, на V4.1 Flash около $0.08. Отсюда два вывода: дорогие модели стоит открывать только авторизованным пользователям, а дневной лимит на самом ключе обязателен, потому что счётчик в памяти сбрасывается при перезапуске процесса и не работает при нескольких экземплярах сервера. Для нескольких экземпляров вынесите счётчик в Redis или другую общую базу.

Что ещё добавить

  • Ограничение источников (CORS) и HTTPS на стороне сервера.
  • Журнал: id пользователя, модель, usage из ответа. Не логируйте тексты диалогов без необходимости.
  • Явные сообщения об ошибках для пользователя: при ответе 402 от API (не хватает баланса) покажите «сервис временно недоступен», а не сырой текст. Основные коды шлюза: 401 ключ, 402 баланс, 403 модель не разрешена ключу, 429 лимит ключа или провайдера, 503 провайдер недоступен или модель выключена.
  • Тот же сервер можно использовать для разных моделей, потому что они доступны по одному адресу и в одном формате.

Частые вопросы

Можно ли просто спрятать ключ в коде фронтенда?

Нет. Всё, что отправляется в браузер, пользователь может прочитать. Единственный надёжный путь: ключ только на сервере.

Подойдёт ли это для Gemini и других моделей?

Да. Прокси передаёт строку model, а шлюз принимает модели разных поставщиков в одном формате. Добавьте нужное имя, например gemini-3.8-flash, в ALLOWED_MODELS.

Почему не использовать только лимиты ключа?

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

Что делать, если ключ всё же утёк?

Создайте новый ключ, удалите старый и проверьте историю расходов. Порядок действий описан в статье про утечку ключа.

Чтобы запустить прокси, создайте ключ в кабинете Zukko, задайте ему лимиты и подставьте в переменную окружения. Оплата: СБП, криптовалюта или карты MasterCard.

zukko

Платите в 25×
меньше

за Claude Code & ChatGPT

Один ключ fetch_* и base URL api.zukko.pro — в Cursor, Claude Code, Codex и любом SDK.