How to hide your OpenAI API key in a frontend app
If your app calls the OpenAI API (or Anthropic, Gemini, Replicate, ElevenLabs, or any paid AI API) straight from React code, your secret key is in the JavaScript that every visitor downloads. Anyone can open developer tools, copy it, and use it on your bill. Putting the key in an environment variable does not change that if the variable is bundled into the browser build.
The only real fix is architectural: the browser talks to your server, and only your server talks to OpenAI. This guide shows how to do that with a Supabase Edge Function (the usual backend for Lovable and Bolt apps) or a Next.js route, how to stop the new endpoint from becoming an open proxy, and how to clean up the leaked key.
Why "hiding" the key in the frontend never works
AI tools often generate code like this when you ask for a chat feature:
// DON'T: this runs in the browser
import OpenAI from "openai";
const openai = new OpenAI({
apiKey: import.meta.env.VITE_OPENAI_API_KEY,
dangerouslyAllowBrowser: true,
});The official OpenAI SDK refuses to run in a browser unless you pass dangerouslyAllowBrowser: true, precisely because it would expose your key. Whatever goes into VITE_OPENAI_API_KEY is replaced with the literal string at build time, so it ships to every visitor. Minification, splitting the key into pieces, base64 encoding, or loading it from a config file all end the same way: the browser needs the real key to make the call, so the visitor can get it too.
How to check if your key is exposed
- Open your live site, open developer tools (F12), and search all scripts (Ctrl+Shift+F or Cmd+Option+F in Chrome's Sources panel) for
sk-anddangerouslyAllowBrowser. - In the Network tab, use your AI feature and look at the request. If it goes straight to
api.openai.comwith anAuthorization: Bearer sk-...header, the key is public. - Check your repository for committed
.envfiles and keys in history.
RowLocked's free scan searches your live JavaScript bundles for secret-shaped strings, including AI provider keys, and masks them in the report. For the general problem, see Exposed API key in JavaScript.
Step 1: Rotate the key first
If the key has been in a deployed frontend, assume someone has it. In the OpenAI dashboard, go to API keys, create a new secret key, and revoke the old one once the new architecture is live. While you are there:
- Set a monthly budget and usage alerts in the billing settings, so a future leak has a ceiling.
- Use a project-scoped key with only the permissions your app needs.
- Check usage for spikes you do not recognize.
Step 2: Move the call into a Supabase Edge Function
Store the new key as a function secret. It never goes into your frontend code or repository:
supabase secrets set OPENAI_API_KEY=sk-your-new-keyThen create a function that accepts the user's message, checks who is calling, and calls OpenAI server-side:
// supabase/functions/chat/index.ts
import { createClient } from "npm:@supabase/supabase-js@2";
const cors = {
"Access-Control-Allow-Origin": "https://your-app.com",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
Deno.serve(async (req) => {
if (req.method === "OPTIONS") return new Response("ok", { headers: cors });
// 1. Identify the caller from their Supabase session
const supabase = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_ANON_KEY")!, {
global: { headers: { Authorization: req.headers.get("Authorization") ?? "" } },
});
const { data: { user } } = await supabase.auth.getUser();
if (!user) return new Response("Sign in required", { status: 401, headers: cors });
// 2. Validate input so nobody can send huge prompts
const { message } = await req.json().catch(() => ({ message: "" }));
if (typeof message !== "string" || message.length === 0 || message.length > 4000) {
return new Response("Invalid message", { status: 400, headers: cors });
}
// 3. Call OpenAI with the server-side key; you choose the model and limits, not the client
const r = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${Deno.env.get("OPENAI_API_KEY")}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "gpt-4o-mini",
max_tokens: 500,
messages: [
{ role: "system", content: "You are a helpful assistant for our app." },
{ role: "user", content: message },
],
}),
});
if (!r.ok) return new Response("Upstream error", { status: 502, headers: cors });
const data = await r.json();
return Response.json({ reply: data.choices?.[0]?.message?.content ?? "" }, { headers: cors });
});Replace the model name with whichever model your app uses. Note what the client no longer controls: the model, the system prompt, and the token limit are all fixed on the server. If you let the client send any model and any max_tokens, someone can use your endpoint as a free, unlimited proxy to the most expensive model.
Call it from the frontend with the Supabase client, which attaches the signed-in user's token automatically:
const { data, error } = await supabase.functions.invoke("chat", {
body: { message: input },
});Then delete the browser OpenAI code and the VITE_OPENAI_API_KEY variable entirely.
Step 3 (Next.js alternative): a server route
If your app is Next.js, a route handler does the same job. Use a variable name without the NEXT_PUBLIC_ prefix so it stays on the server:
// app/api/chat/route.ts
import { NextResponse } from "next/server";
export async function POST(req: Request) {
// Check your own session here (NextAuth, Supabase, Clerk...) and reject anonymous callers.
const { message } = await req.json();
if (typeof message !== "string" || message.length > 4000) {
return NextResponse.json({ error: "Invalid message" }, { status: 400 });
}
const r = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ model: "gpt-4o-mini", max_tokens: 500, messages: [{ role: "user", content: message }] }),
});
const data = await r.json();
return NextResponse.json({ reply: data.choices?.[0]?.message?.content ?? "" });
}Step 4: Add a rate limit
Authentication stops anonymous abuse, but one signed-up account can still loop your endpoint. A simple per-user daily limit in Postgres is enough for most apps:
create table public.ai_usage (
user_id uuid not null references auth.users (id) on delete cascade,
day date not null default current_date,
calls int not null default 0,
primary key (user_id, day)
);
alter table public.ai_usage enable row level security;
-- No policies: only the Edge Function (with the secret key) touches this table.
create or replace function public.bump_ai_usage(p_user uuid, p_limit int)
returns boolean language plpgsql security definer set search_path = '' as $$
declare n int;
begin
insert into public.ai_usage (user_id) values (p_user)
on conflict (user_id, day) do update set calls = public.ai_usage.calls + 1
returning calls into n;
return n <= p_limit;
end;
$$;
revoke execute on function public.bump_ai_usage(uuid, int) from public, anon, authenticated;In the function, call it with an admin client before calling OpenAI, and return HTTP 429 when it returns false:
const admin = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!);
const { data: allowed } = await admin.rpc("bump_ai_usage", { p_user: user.id, p_limit: 50 });
if (!allowed) return new Response("Daily limit reached", { status: 429, headers: cors });Execute is revoked from client roles, so users cannot reset or inflate their own counter; only server code with the secret key can call it.
A prompt for Lovable or Bolt
Move every call to OpenAI (and any other paid AI API) out of frontend code into a Supabase Edge Function named "chat". Read the API key from a function secret called OPENAI_API_KEY, never from a VITE_ variable. In the function: require a signed-in user, validate and cap the input length, fix the model, system prompt and max_tokens on the server, and enforce a per-user daily limit. Call it from the frontend with supabase.functions.invoke. Remove dangerouslyAllowBrowser and the old key from the codebase.After the change, repeat the checks above: search the deployed bundle for sk- and confirm network requests go to your function, not to api.openai.com.
Frequently asked questions
Is it OK to use dangerouslyAllowBrowser for a prototype?
Only if the key has a tiny budget, the prototype is not public, and you rotate the key before sharing the link. Anything deployed to a public URL should use a backend.
Can I restrict my OpenAI key to my domain?
OpenAI secret keys are not meant to be used from browsers and cannot be safely restricted by website referrer. Keep them server-side and use budgets and project-scoped keys to limit damage.
Does this apply to other AI APIs?
Yes. Anthropic, Google Gemini, Replicate, ElevenLabs, Stability and similar APIs use secret keys that must stay on a server. The same Edge Function pattern works for all of them.
My Supabase anon key is also in the frontend. Is that the same problem?
No. The Supabase anon or publishable key is designed to be public and is protected by Row Level Security. See Is the Supabase anon key safe to expose?