Guides15 min readPublished 2026-08-06

OpenAI API Key in Browser? Build a Secure JavaScript Proxy

Keep an OpenAI or LumeAPI key out of React and browser bundles. Build a controlled Node.js proxy with authentication, validation, limits and leak recovery.

By LumeAPI Engineering Team

OpenAI-Compatible API hub →

Last updated: August 6, 2026

Do not put a long-lived OpenAI API key in a browser, React bundle, Vite variable, mobile app or public desktop package. If client-side JavaScript can send the key, a user can inspect it in DevTools, a bundled source file, a source map or the network request. Encoding, minification and CORS do not turn that credential into a secret.

The safe production pattern is: browser → your authenticated and rate-limited backend → model API. Your backend stores the provider key, validates a narrow request, selects an allowed model, caps output and records abuse-safe usage. A proxy that accepts arbitrary upstream URLs or anonymous unlimited prompts is still unsafe.

LumeAPI is an independent third-party OpenAI-compatible gateway. The same security rule applies to its keys: keep LUMEAPI_KEY on the server and call https://api.lumeapi.site/v1 only from trusted infrastructure.

Short path: Review the OpenAI-compatible API, select an allowlisted ID from models, then put the protected server route below between the browser and the gateway.

Quick Answer

ApproachIs a long-lived model key safe?Why
VITE_OPENAI_API_KEY or NEXT_PUBLIC_*NoPublic-prefixed variables are shipped to the client bundle
Hardcoded or obfuscated JavaScriptNoThe browser must recover the value to send it
dangerouslyAllowBrowser: trueNo for a shared production keyThe SDK warning exists because users can extract the credential
CORS plus a browser keyNoCORS restricts browser behavior, not curl, scripts or stolen credentials
Server environment variable plus controlled proxyYes, with additional controlsThe long-lived provider key never reaches the client
User supplies their own key for a local-only toolDifferent risk modelThe user's own credential still needs clear storage and exfiltration boundaries

In short

A browser cannot both possess a reusable secret and hide it from the person controlling that browser. Move the provider key to a server. Then protect the proxy itself with user authentication, an endpoint-specific schema, model and token allowlists, request-size limits, rate limits, timeouts, logging redaction and spend monitoring. If a key has already been exposed, revoke it first; deleting it from the source repository does not invalidate copies.

Why frontend environment variables do not hide secrets

Build tools replace frontend environment references during compilation or expose them through a runtime configuration object. A user can then find the value in one or more places:

  • Downloaded JavaScript bundles and source maps
  • Browser DevTools Network request headers
  • Browser extensions, injected scripts or compromised dependencies
  • Error monitoring, analytics payloads, screenshots and copied logs
  • Mobile or desktop packages that can be unpacked

The variable name is not the security boundary. VITE_, REACT_APP_ and NEXT_PUBLIC_* explicitly mark values for client use. A .env file protects a server secret only when its value remains in a server process and never enters the client build.

What the official SDK warning means

The official OpenAI Node.js library disables browser use by default and requires the explicit dangerouslyAllowBrowser option. Its documentation warns that enabling the option exposes secret API credentials in client-side code and may allow unauthorized requests and charges.

That flag is an acknowledgement of risk, not encryption. It may be reasonable for short-lived credentials in a controlled experiment or for a user who deliberately supplies their own key to a local-only application. It is not a safe way to ship one company key to every visitor.

The secure request boundary

text
Browser
  └─ authenticated request with application session
       └─ your backend /api/ai
            ├─ verify user/session
            ├─ rate-limit by user and IP
            ├─ validate prompt and request size
            ├─ choose from server-owned model allowlist
            ├─ cap output and timeout
            └─ add LUMEAPI_KEY server-side
                 └─ https://api.lumeapi.site/v1/chat/completions

The browser may know your /api/ai URL. Hiding an endpoint is not security. The backend must remain safe when a user calls it directly outside your webpage.

Install a narrow Node.js proxy

This Express example assumes your application already has authentication middleware that sets req.user.id. That is an intentional boundary: model proxy code should consume an authenticated identity, not invent a login system.

bash
npm install express helmet express-rate-limit zod openai

Store the model key only in the server environment:

bash
export LUMEAPI_KEY='your-lumeapi-key'
export LUMEAPI_MODEL='gpt-5.4-mini'

Never prefix the secret with VITE_, NEXT_PUBLIC_ or another client-exposure convention.

Build the controlled proxy route

javascript
import express from 'express';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import OpenAI from 'openai';
import { z } from 'zod';

const apiKey = process.env.LUMEAPI_KEY;
if (!apiKey) throw new Error('LUMEAPI_KEY is missing');

const allowedModels = new Set(['gpt-5.4-mini']);
const defaultModel = process.env.LUMEAPI_MODEL ?? 'gpt-5.4-mini';
if (!allowedModels.has(defaultModel)) throw new Error('Unapproved model');

const client = new OpenAI({
  apiKey,
  baseURL: 'https://api.lumeapi.site/v1',
  timeout: 30_000,
  maxRetries: 1,
});

const app = express();
app.disable('x-powered-by');
app.use(helmet());
app.use(express.json({ limit: '20kb' }));

const aiLimit = rateLimit({
  windowMs: 60_000,
  limit: 12,
  standardHeaders: 'draft-8',
  legacyHeaders: false,
  keyGenerator: (req) => req.user?.id ?? req.ip,
});

const RequestSchema = z.object({
  prompt: z.string().trim().min(1).max(4_000),
}).strict();

function requireUser(req, res, next) {
  if (!req.user?.id) return res.status(401).json({ error: 'sign_in_required' });
  return next();
}

app.post('/api/ai', requireUser, aiLimit, async (req, res) => {
  const parsed = RequestSchema.safeParse(req.body);
  if (!parsed.success) {
    return res.status(400).json({ error: 'invalid_request' });
  }

  try {
    const result = await client.chat.completions.create({
      model: defaultModel,
      messages: [{ role: 'user', content: parsed.data.prompt }],
      max_tokens: 400,
    });

    return res.json({
      text: result.choices[0]?.message?.content ?? '',
      requestId: result._request_id ?? null,
    });
  } catch (error) {
    console.error('model_request_failed', {
      status: error?.status ?? 500,
      userId: req.user.id,
    });
    return res.status(502).json({ error: 'model_request_failed' });
  }
});

app.listen(3000, () => console.log('Server listening on :3000'));

Connect your real session middleware before requireUser. Do not copy a username from a browser header and treat it as authenticated identity. Behind a reverse proxy, configure trusted proxy handling carefully so an attacker cannot choose the IP used by the limiter.

Call only your backend from the browser

javascript
const response = await fetch('/api/ai', {
  method: 'POST',
  credentials: 'same-origin',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ prompt: 'Summarize these release notes.' }),
});

if (!response.ok) {
  throw new Error(`Application request failed: ${response.status}`);
}

const data = await response.json();
document.querySelector('#answer').textContent = data.text;

The browser sends the application's authenticated session, preferably through a Secure, HttpOnly and appropriately scoped cookie. It never receives the LumeAPI key. Add CSRF protection when your authentication and cross-origin design require it.

A proxy is not secure until it limits abuse

ControlThreat it reducesMinimum production decision
AuthenticationAnyone spending your balanceRequire a real user or tenant identity
Per-user and per-IP rate limitsAutomated request floodsBound requests and tokens per time window
Model allowlistUser selects a premium or unsupported modelKeep model choice server-owned
Request schema and size capArbitrary pass-through payloadsAccept only the fields the feature needs
Output capUnbounded completions and billsSet a route-specific maximum
Timeout and retry capHanging or multiplied attemptsUse one retry owner and total deadline
Content/feature policyProxy becomes a general-purpose relayRestrict the endpoint to its product task
Usage attributionCannot identify an abusive userLog user, route, model, outcome and token totals
RedactionLogs become a second leakNever log Authorization or full private prompts by default

Do not accept a base_url from the browser and fetch it on the server. That creates a server-side request forgery risk. Do not accept an arbitrary model ID or pass every request field directly upstream.

Why CORS and obfuscation are not authentication

CORS tells cooperating browsers which origins may read a response. An attacker with a stolen key can call the provider from a server, command line or custom client that does not enforce your browser's CORS policy.

Minification changes formatting. Base64 changes representation. Encryption embedded in a browser bundle must also ship the decryption path or send a decryptable value. None prevents extraction when the browser needs the usable credential.

Domain allowlists can add defense in depth when a provider supports them, but request origins and referrers are not a substitute for keeping a billing credential server-side.

Test the proxy like an attacker

  1. Search the production bundles and source maps for key prefixes and the real credential.
  2. Inspect every browser request and confirm no provider Authorization header appears.
  3. Call /api/ai without a session and require 401.
  4. Send an unknown field, oversized prompt and malformed JSON; require rejection.
  5. Attempt to supply another model or upstream URL; the schema must reject it.
  6. Exceed the user and IP budgets; require 429 from your application.
  7. Disconnect or exceed the timeout; confirm work stops and retry count stays bounded.
  8. Inspect logs, analytics and error reports for secrets and private prompt bodies.
  9. Confirm usage records map requests to users or tenants without storing unnecessary content.
  10. Rotate the provider key and prove the service can deploy the replacement safely.

This checklist is the page's second unique module: it validates the security property rather than merely drawing a proxy diagram.

Estimate the maximum abuse exposure

Rate limits should connect to a dollar budget. For a route with a maximum of 4,000 input and 400 output tokens, using the dated gpt-5.4-mini LumeAPI rates of $0.225/$1.35 per million:

text
maximum model cost per accepted request
= (4,000 × $0.225 + 400 × $1.35) / 1,000,000
= $0.00144

At 12 requests per minute, one continuously active identity has a simplified ceiling of about $1.04 per hour. That is not a complete financial control: distributed accounts, retries, other routes and changed rates can raise exposure. Combine per-user limits with tenant budgets, global circuit breakers and usage alerts.

If the key is already exposed

  1. Revoke the key immediately; do not wait to confirm misuse.
  2. Review usage, unfamiliar models, timestamps, IP evidence and project limits.
  3. Create a replacement key with the smallest available scope.
  4. Put it in server secret storage and redeploy every legitimate consumer.
  5. Remove it from source, build artifacts, logs, tickets, screenshots and CI output.
  6. Treat Git history and cached browser bundles as still exposed.
  7. Add secret scanning and an ownership/rotation record.

Rewriting Git history can reduce accidental discovery but does not make the old key safe. Revocation is the security action.

A realistic production scenario

A developer builds a React support widget and places LUMEAPI_KEY in a Vite .env file. The build works, but the value is embedded into the downloaded JavaScript and visible in the Network Authorization header. Moving the value to an Express server stops direct exposure, yet the first proxy version remains anonymous and accepts any model.

The production fix adds the application's session middleware, a prompt-only schema, a standard-model allowlist, output cap, user/IP rate limits, a global budget alert and redacted request logging. The frontend calls only /api/ai. Security comes from the server enforcing who may spend what—not from hiding the URL.

What most guides get wrong

Many tutorials say “put the key in .env” without distinguishing server and client environments. In Vite or another browser build, an exposed variable still becomes public. Other tutorials suggest a serverless proxy but omit authentication, request constraints and spend controls, turning one protected key into an unlimited public relay.

The correct design protects both credentials and budget. A key can remain hidden while an unprotected proxy still lets attackers spend it.

Expert take

Assume anything delivered to a user-controlled device is public. Keep provider credentials in trusted server infrastructure, give the browser only an application session, and make the proxy narrower than the upstream API. The security test is not “Can I see the key?” It is “Can an unauthorized caller cause provider work or choose cost?” Both answers must be no.

FAQ

Can I hide an OpenAI API key in React or Vite .env?

No. Variables exposed to a client build are downloadable. Use a backend or serverless function that retains the provider key.

Is dangerouslyAllowBrowser safe?

It explicitly permits browser use despite credential exposure risk. Do not use it to ship one long-lived production key to untrusted users.

Does CORS protect a leaked API key?

No. CORS is enforced by browsers. A stolen key can be used from other clients and servers.

Is a backend proxy enough?

Not by itself. Authenticate users, validate a narrow schema, restrict models and output, rate-limit, monitor spend and redact logs.

What should I do after committing a key to GitHub?

Revoke it immediately, inspect usage, create a replacement and remove the secret from code and history. Removal without revocation is insufficient.

Sources and verification

Both JavaScript blocks were syntax-checked on August 6, 2026. The Express sample intentionally depends on the application's existing authenticated req.user; it is not a complete identity provider. No live key or attack traffic was used.

Ready to call these models?

Create a LumeAPI key in under a minute — one OpenAI-compatible gateway for GPT, Claude, Gemini, and more.