Your Own Neural Network Proxy on Node.js: Keep the API Key Off the Frontend
A small Express server between your frontend and the model API: the key stays on the server, and models, request size and rate are capped.
4 min · · development · API keys
If you call a model API straight from the browser, the key ends up in the page code or the Network tab, and any visitor can copy it. The standard fix is to put your own server layer between the frontend and the API. Below is a small Node.js OpenAI proxy on Express: the key lives in an environment variable, the model list and request size are capped, the answer streams, and request rate is counted per user. The same approach works for Gemini models (for example gemini-3.8-flash): only the model string changes.
Why a proxy
You cannot hide a key in a frontend: minification and obfuscation do not help, because the API request is visible in the browser anyway. A server proxy solves four problems at once:
- the key stays on the server and never reaches users;
- you decide which models are available and what request size is acceptable;
- you can limit the request rate per user and tie spending to an account;
- the system prompt stays on the server and is not exposed to the client.
General key-storage rules are collected in the article on API key security.
Setup
mkdir ai-proxy && cd ai-proxy
npm init -y
npm install express openaiAdd "type": "module" to package.json. Node.js 18 or newer is required. Create a separate key for this project in the Zukko dashboard and immediately give it a daily dollar limit and a list of allowed models: this is the last line of defense if something goes wrong in the code. How to do it is described in the article on key limits.
export ZUKKO_API_KEY="fetch_key_for_this_project"Server code
// 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;Note three details. First, the system role comes only from the server, and only user and assistant are accepted from the client: otherwise a visitor could inject their own system prompt. Second, the checked chunk.choices?.[0] is needed because the last chunk of the stream arrives with an empty choices list. Third, when the client closes the connection the API request is aborted, so generation does not keep running for nobody; tokens generated before the disconnect may still be charged.
Reading the stream in the frontend
EventSource cannot send POST, so use fetch and read the body as a stream:
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;
}
}For Next.js the same is done in a Route Handler. A deeper look at streaming on Express is in the article on streaming in Express.
What abuse could cost
Let us compute the worst case under the limits in the code. The code caps characters, so we take a generous illustrative bound of 8000 input tokens and 800 output tokens. Prices as of October 18, 2026, $ per 1M tokens.
deepseek-v4.1-flash(0.013 / 0.038): 8000 × 0.013 / 1M + 800 × 0.038 / 1M = 0.000104 + 0.0000304 = about $0.00013.claude-sonnet-5-5(0.4 / 2): 8000 × 0.4 / 1M + 800 × 2 / 1M = 0.0032 + 0.0016 = $0.0048.
A limit of 10 requests per minute allows up to 600 requests per hour per user. On Sonnet that is 600 × 0.0048 = $2.88 per hour, on V4.1 Flash about $0.08. Two conclusions follow: open expensive models only to authenticated users, and a daily limit on the key itself is mandatory, because the in-memory counter resets when the process restarts and does not work across several server instances. For several instances move the counter to Redis or another shared store.
What else to add
- Origin restrictions (CORS) and HTTPS on the server side.
- A log: user id, model,
usagefrom the response. Do not log dialogue texts unless necessary. - Clear messages for the user: on a 402 from the API (not enough balance) show "service temporarily unavailable" rather than raw text. The main gateway codes: 401 key, 402 balance, 403 model not allowed for the key, 429 key or provider limit, 503 provider unavailable or model switched off.
- The same server serves different models because they are available at one address and in one format.
FAQ
Can I just hide the key in the frontend code?
No. Anything sent to the browser can be read by the user. The only reliable path is to keep the key on the server.
Does this work for Gemini and other models?
Yes. The proxy passes the model string, and the gateway accepts models from different vendors in one format. Add the name, for example gemini-3.8-flash, to ALLOWED_MODELS.
Why not rely on key limits alone?
Key limits protect the budget as a whole but do not tell one user from another: one abuser can exhaust the daily limit for everyone. So you need both levels: a per-user counter and a limit on the key.
What if the key leaks anyway?
Create a new key, delete the old one and check the spending history. The steps are in the article about a leaked key.
To run the proxy, create a key in the Zukko dashboard, set limits and put it into the environment variable. Payment: SBP, cryptocurrency or MasterCard cards.
zukko
Pay 25×
less
for Claude Code & ChatGPT
One fetch_* key and base URL api.zukko.pro — for Cursor, Claude Code, Codex, and any SDK.
Related articles
- DeepSeek API in Python: Requests, Streaming, JSON and Error HandlingReady code for DeepSeek V4.1 Flash and V4 Pro on the OpenAI SDK: a request, streaming, JSON parsing and careful error handling.
- Gemini API in Python: requests via the OpenAI SDK and OpenAI formatA working Gemini API example in Python with no extra library: change base_url in the OpenAI SDK, add streaming and JSON, and compare with google-genai.
- Anthropic SDK for Python: base_url, key and a first request via a gatewayThe official anthropic package for Python just needs a different address. We cover base_url, the key, streaming, token counting, caching and errors.
- Claude Code security: a checklist before running the agent on a projectThe agent reads files and runs commands, so security settings belong before the first launch. A checklist: permissions, secrets, sandbox, a separate key with a limit.