How to Hide an API Key in the Frontend: The Honest Answer
The question "how to hide API key in frontend" has a short answer: you can't. Anything your app sends to the browser, including every NEXT_PUBLIC_, VITE_ and EXPO_PUBLIC_ variable, can be read by anyone who opens developer tools. The fix is to keep secret keys on a server you control and have the frontend call that server instead.
How to hide an API key in frontend code
You don't hide it; you move it. Some keys are designed to be public and rely on rules at the provider. Secret keys, such as OpenAI, Anthropic, Stripe secret and Supabase secret keys, must never reach the browser. Put them in a server route or serverless function that checks the caller, limits requests, and calls the provider for you.
That's the whole idea. The rest of this post is which keys fall on which side, and the code for the server part.
Why environment variables don't hide anything
A .env file feels private, so it's natural to assume anything read from it stays private. It doesn't. What matters is whether the variable is read by code that runs in the browser, and the framework prefix decides that.
- Next.js. The environment variables guide says a
NEXT_PUBLIC_value is inlined "at build time, into the js bundle that is delivered to the client." Variables without the prefix are only available on the server. - Vite. The env docs say variables prefixed with
VITE_are exposed in client-side code, and warn that they "should not contain any sensitive information such as API keys." - Expo. The Expo guide says not to store sensitive info in
EXPO_PUBLIC_variables, because they "will be visible in plain-text in your compiled application."
So if your AI assistant wrote VITE_OPENAI_API_KEY or NEXT_PUBLIC_STRIPE_SECRET_KEY, the key is in your JavaScript bundle. Minifying or obfuscating the code doesn't change that; the string still has to be there for the request to work.
Is an API key sensitive? Which keys are meant to be public
Not every key is a secret. Some only identify your project, and the provider protects your data with rules on its own servers. Others are bearer credentials: whoever holds one can act as you.
| Key | Safe in the browser? | What actually protects you |
|---|---|---|
Supabase anon or publishable key (sb_publishable_...) | Yes, by design | Row Level Security. Supabase's API keys guide says anyone can read it, so it only reaches what RLS allows |
Stripe publishable key (pk_live_...) | Yes, by design | It can create tokens and payment methods but can't charge or read account data, per Stripe's API keys docs |
Firebase web config apiKey | Yes, if restricted to Firebase services | Firebase Security Rules and App Check |
| OpenAI or Anthropic API key | No | Nothing. Anyone holding it can run requests on your bill |
Stripe secret or restricted key (sk_live_..., rk_live_...) | No | Nothing in the browser. Depending on its permissions, it can move money and read customer data |
Supabase secret or service_role key | No | Nothing. It bypasses Row Level Security entirely |
| Database URL with a password | No | Nothing. It's a direct login to your database |
Email API keys such as Resend (re_...) | No | Nothing. Anyone can send mail as your domain |
The keys in the first three rows are only as safe as the rules behind them. A public Supabase key with a table that has RLS turned off lets anyone read that table; is the Supabase anon key safe walks through checking yours.
Supabase adds one guard of its own: its newer secret keys don't work in a browser. Supabase matches on the User-Agent header and returns 401. That doesn't make a leaked one safe, because a script outside a browser can still use it.
Is a Firebase API key in the frontend safe?
Yes, with a condition. Firebase's API keys page says keys restricted to Firebase services "do not need to be treated as secrets," because your data is protected by Security Rules and App Check, not by the key. The same page warns never to add the Gemini Developer API to a publicly accessible key's allowlist. If your app calls Gemini, that call needs its own restricted key on a server, or Firebase AI Logic.
What dangerouslyAllowBrowser means
It's an easy pattern to end up with, because it works on the first try:
// src/lib/ai.ts: runs in the browser. Don't ship this.
import OpenAI from 'openai'
const openai = new OpenAI({
apiKey: import.meta.env.VITE_OPENAI_API_KEY,
dangerouslyAllowBrowser: true,
})The OpenAI SDK refuses to run in a browser by default. The openai-node README says browser support is "disabled by default to avoid exposing your secret API credentials," and that the flag "exposes your secret API credentials in the client-side code." Anthropic's TypeScript SDK has a flag with the same name for the same reason.
So the flag isn't a setting to tune. It's the SDK telling you the key is about to become public. The README names a few cases where that's acceptable, such as internal tools with trusted users or short-lived development keys. A public app with real users isn't one of them.
If you'd rather not read every file yourself, a free Tuvio scan of your repository flags this shape: an LLM SDK client created with dangerouslyAllowBrowser: true whose key comes from a public env var such as NEXT_PUBLIC_ or VITE_. It also flags secrets committed to the repo, and because it reads git history (the full history where it can), that includes keys deleted in a later commit. It reads the code, not your live site, so the browser check below is still worth doing. See what a scan checks.
The fix: a small server proxy that holds the key
Move the call to code that runs on a server. The browser sends your server a prompt; your server checks the caller, adds the key and calls the provider. The proxy should:
- Read the key from a server-only variable (no public prefix).
- Reject callers who aren't signed in.
- Limit how many requests each user can make.
- Cap the input size, and choose the model on the server, so a caller can't pick the most expensive one.
Next.js route handler example
Put the key in .env.local without a prefix, and in your host's environment settings for production:
OPENAI_API_KEY=sk-proj-...Then add a route handler. This one uses the Supabase server client from Supabase's Next.js setup guide to identify the user; swap in your own auth check if you use something else.
// app/api/chat/route.ts
import OpenAI from 'openai'
import { createClient } from '@/lib/supabase/server'
const WINDOW_MS = 60_000
const MAX_PER_WINDOW = 10
const hits = new Map<string, number[]>()
function tooMany(userId: string) {
const now = Date.now()
const recent = (hits.get(userId) ?? []).filter((t) => now - t < WINDOW_MS)
recent.push(now)
hits.set(userId, recent)
return recent.length > MAX_PER_WINDOW
}
export async function POST(request: Request) {
const supabase = await createClient()
const { data } = await supabase.auth.getClaims()
const userId = data?.claims?.sub
if (!userId) {
return Response.json({ error: 'Sign in first' }, { status: 401 })
}
if (tooMany(userId)) {
return Response.json({ error: 'Too many requests' }, { status: 429 })
}
const { prompt } = await request.json()
if (typeof prompt !== 'string' || prompt.length > 4000) {
return Response.json({ error: 'Invalid prompt' }, { status: 400 })
}
const openai = new OpenAI() // reads OPENAI_API_KEY on the server
const response = await openai.responses.create({
model: 'gpt-5.5',
input: prompt,
})
return Response.json({ text: response.output_text })
}The browser calls your route, not OpenAI:
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
})
const { text } = await res.json()The in-memory Map is the simplest rate limit that works on one long-running server. On serverless hosts each instance has its own memory, so for production keep the counts in a shared store such as Redis or a database table, like the next example does.
Supabase Edge Function example
If your app is a Vite or Lovable frontend on Supabase, an Edge Function is the natural place for the key. Supabase's Edge Function auth guide uses the withSupabase wrapper, which checks the caller's session and handles CORS for you.
First, a table to count requests. RLS is on with no policies and the browser roles get no grants, so only the function's admin client can read or write it. Supabase is changing its default grants on new tables, so the explicit grant keeps this working either way.
create table public.ai_requests (
id bigint generated always as identity primary key,
user_id uuid not null references auth.users (id) on delete cascade,
created_at timestamptz not null default now()
);
alter table public.ai_requests enable row level security;
revoke all on table public.ai_requests from anon, authenticated;
grant select, insert on table public.ai_requests to service_role;Then the function:
// supabase/functions/chat/index.ts
import { withSupabase } from 'npm:@supabase/server@1'
import OpenAI from 'npm:openai'
const MAX_PER_HOUR = 30
export default {
fetch: withSupabase({ auth: 'user' }, async (req, ctx) => {
const { prompt } = await req.json()
if (typeof prompt !== 'string' || prompt.length > 4000) {
return Response.json({ error: 'Invalid prompt' }, { status: 400 })
}
const userId = ctx.userClaims!.id
const since = new Date(Date.now() - 60 * 60 * 1000).toISOString()
const { count, error } = await ctx.supabaseAdmin
.from('ai_requests')
.select('id', { count: 'exact', head: true })
.eq('user_id', userId)
.gte('created_at', since)
if (error) {
return Response.json({ error: 'Try again later' }, { status: 500 })
}
if ((count ?? 0) >= MAX_PER_HOUR) {
return Response.json({ error: 'Too many requests' }, { status: 429 })
}
await ctx.supabaseAdmin.from('ai_requests').insert({ user_id: userId })
const openai = new OpenAI({ apiKey: Deno.env.get('OPENAI_API_KEY') })
const response = await openai.responses.create({
model: 'gpt-5.5',
input: prompt,
})
return Response.json({ text: response.output_text })
}),
}Store the key as a function secret and deploy, following the Edge Function secrets guide:
supabase secrets set OPENAI_API_KEY=sk-proj-...
supabase functions deploy chatThe frontend calls it with the Supabase client, which sends the user's session automatically:
const { data, error } = await supabase.functions.invoke('chat', {
body: { prompt },
})Leave verify_jwt at its default for this function, so the platform also checks the token before your code runs. The same shape works on any serverless platform: a function that holds the key, checks the user, counts requests, and returns only the result.
How to check your deployed app for exposed keys
You can see exactly what a stranger sees. Open your live site in Chrome, open DevTools, and search all loaded files with Cmd+Option+F on a Mac or Ctrl+Shift+F elsewhere. Search for:
sk-proj-andsk-ant-: OpenAI project keys and Anthropic keys. Older OpenAI keys start with justsk-, which also matches ordinary words liketask-, so expect some noisesk_live_andrk_live_: Stripe secret and restricted keyssb_secret_: Supabase secret keysdangerouslyAllowBrowser: survives minification, because it's an object keyeyJ: JWTs. A Supabase legacy key is one; the anon one is fine, theservice_roleone is not
Click around first, so code that loads later (other pages, the chat screen) is included. Then open the Network tab, use the feature that calls the AI, and look at the request headers. If the browser itself sends Authorization: Bearer sk-... to api.openai.com, or an x-api-key header to api.anthropic.com, the key is in the page.
To tell which kind of JWT you found without pasting it into a website, decode the middle part locally:
node -e "console.log(Buffer.from(process.argv[1].split('.')[1], 'base64url').toString())" 'eyJ...'"role":"anon" is the public key. "role":"service_role" means the secret key is in your bundle.
If you find a secret, treat it as leaked: it was public from the moment you deployed. Rotate it at the provider first, then move the call behind a proxy. Accidentally pushed a .env file to GitHub has a table of where to rotate each common key.
A short checklist
- List every variable with a
NEXT_PUBLIC_,VITE_orEXPO_PUBLIC_prefix, and confirm each is designed to be public. - Search your code for
dangerouslyAllowBrowserand remove it. - Move every secret-key call into a route handler or serverless function.
- In that function, check the user, rate-limit, cap input, and pick the model server-side.
- Rotate any secret that ever shipped in a bundle.
- Search your live site in DevTools for the prefixes above.
- Check that the public keys you keep are backed by rules: RLS for Supabase, Security Rules for Firebase.
For the rest of the prelaunch pass, the vibe coding security checklist covers what usually comes next.