How to hide API key in React and keep it off the browser
If you want to hide API key in React code, you can't: anything shipped to the browser is readable by every visitor. Keep the key on a server instead. A small server function, called a proxy (a Next.js route handler, Supabase Edge Function or Cloudflare Worker), calls the API for you. Then require login and add a rate limit so strangers can't use it as a free pipe.
QUICK CHECKLIST
- Search your repo and build output for keys
- Revoke any secret key that already shipped to the browser
- Store the key as a server secret, never VITE_ or NEXT_PUBLIC_
- Proxy the call through a route or edge function
- Require login and rate limit the proxy
- Set a spend cap at the provider
Why you can't hide API key in React code
React runs in the visitor's browser. Your build packs your code into a bundle (the JavaScript files your site serves), and every visitor downloads it and can read it. That includes values you read through import.meta.env.VITE_... or process.env.NEXT_PUBLIC_....
The Vite docs say VITE_* values end up in your client bundle, so VITE_-prefixed variables must not hold anything sensitive. At build time, Next.js copies NEXT_PUBLIC_ values straight into the JavaScript it sends to the browser. Our guide to public env vars covers the prefix rules.
An API key in frontend code is public, whatever you name the variable. Put a small server between the browser and the API. The browser sends the user's input to your route, and your route adds the key and forwards the call.
How people find an API key in your bundle
Anyone can find a key without hacking anything:
- Open DevTools, go to the Network tab and click the request your app makes. If the browser calls the API directly, the
Authorizationheader is right there. - Download your JavaScript file and search it for
sk-orBearer. - Let a bot run the same search automatically.
The damage depends on the key. An OpenAI key lets someone run requests on your account and bill you. A Stripe secret key or a Supabase service role key can read or change real data. See leaked API keys for what to check once a key has gone public, and rotate API keys after a breach for the order to do it in.
The proxy in Next.js, Supabase and Cloudflare
A good API proxy does five jobs, in this order:
- Check who is calling.
- Limit how often they call.
- Validate the input.
- Add the key from a server secret.
- Forward the request.
Skip the first two and you've built a free API for the whole internet.
The wrong version is a Vite app calling OpenAI straight from the browser. The right one is a Next.js route handler that receives only the prompt.
// src/chat.ts (Vite, runs in the browser)
const res = await fetch("https://api.openai.com/v1/responses", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${import.meta.env.VITE_OPENAI_API_KEY}`,
},
body: JSON.stringify({ model: "YOUR_MODEL", input: prompt }),
})// app/api/chat/route.ts
import { NextResponse } from "next/server"
import { allow } from "@/lib/rate-limit"
import { getUser } from "@/lib/auth" // your own session check
export async function POST(req: Request) {
const user = await getUser(req)
if (!user) return new NextResponse("Unauthorized", { status: 401 })
if (!allow(user.id)) return new NextResponse("Too many requests", { status: 429 })
const { prompt } = await req.json()
if (typeof prompt !== "string" || prompt.length > 2000) {
return new NextResponse("Bad request", { status: 400 })
}
const upstream = await fetch("https://api.openai.com/v1/responses", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: JSON.stringify({ model: "YOUR_MODEL", input: prompt }),
})
return new NextResponse(upstream.body, { status: upstream.status })
}The route reads process.env.OPENAI_API_KEY, which has no NEXT_PUBLIC_ prefix, so Next.js keeps it on the server. getUser stands for your own auth check. The limiter goes in lib/rate-limit.ts:
// lib/rate-limit.ts (in memory, one server instance only)
const hits = new Map<string, { count: number; resetAt: number }>()
export function allow(id: string, max = 10, windowMs = 60_000) {
const now = Date.now()
const hit = hits.get(id)
if (!hit || now > hit.resetAt) {
hits.set(id, { count: 1, resetAt: now + windowMs })
return true
}
hit.count += 1
return hit.count <= max
}If your app already uses Supabase, a Supabase Edge Function is the shortest path. Put OPENAI_API_KEY=YOUR_KEY in a git-ignored env file (the Supabase docs use supabase/functions/.env), then push it. Passing the real key on the command line would leave it in your shell history.
supabase secrets set --env-file supabase/functions/.env
The function verifies the caller's login token with auth.getUser(token) before it does anything else. Supabase injects SUPABASE_URL and SUPABASE_ANON_KEY into your functions. Check the current docs, because newer key names are being rolled out.
// supabase/functions/chat/index.ts
import { createClient } from "npm:@supabase/supabase-js@2"
const cors = {
"Access-Control-Allow-Origin": "https://yourapp.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 })
const supabase = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_ANON_KEY")!)
const token = (req.headers.get("Authorization") ?? "").replace("Bearer ", "")
const { data, error } = await supabase.auth.getUser(token)
if (error || !data.user) return new Response("Unauthorized", { status: 401, headers: cors })
// Add a per-user rate limit here (count by data.user.id, return 429)
const { prompt } = await req.json()
if (typeof prompt !== "string" || prompt.length > 2000) {
return new Response("Bad request", { status: 400, headers: cors })
}
const upstream = await fetch("https://api.openai.com/v1/responses", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Deno.env.get("OPENAI_API_KEY")}`,
},
body: JSON.stringify({ model: "YOUR_MODEL", input: prompt }),
})
return new Response(upstream.body, { status: upstream.status, headers: cors })
})A Cloudflare Worker follows the same five jobs. Store the key with npx wrangler secret put OPENAI_API_KEY and read it as env.OPENAI_API_KEY inside the Worker. Cloudflare's docs say not to put sensitive values in vars in the Wrangler config. For local development, put the value in .dev.vars and add that file to .gitignore.
How to hide API key in React, step by step
Do these in order. Step 2 matters most if the key is already deployed.
Find every key the browser can see
Search your repo for
VITE_,NEXT_PUBLIC_and strings likesk-orBearer. Then build the app and search the output folder too, because the bundle is what visitors get.Revoke keys that already shipped
Create a new key at the provider and delete the old one. Removing it from your code isn't enough.
Store the new key as a server secret
Use
.env.localwith no public prefix locally and set the same variable in your host's settings. For Supabase usesupabase secrets set --env-file, for Cloudflarewrangler secret put. Add every env file to.gitignore.Write the proxy
It accepts only the user's input, such as a prompt, and builds the upstream request itself. Don't let the browser send the target URL or the headers.
Add auth, input limits and a rate limit
Require a signed-in user, cap the size of the input and count requests per user. Without this, anyone can use your API proxy and spend your quota.
Set a spend cap at the provider
A cap and an alert put a ceiling on any mistake in the proxy. Check your provider's current docs for where to set it.
Then point your React code at /api/chat and send only { prompt }.
Which keys can stay public and which can't
Some keys are built for browsers, and they're safe only because something else limits what they can do. Supabase says its publishable (anon) key is safe in a browser when Row Level Security (RLS, database rules that decide which rows each user can read or write) is on. Its secret and service_role keys bypass RLS, so they belong on the server only (see the Supabase anon key guide).
Firebase web config only identifies your project, and your Firebase security rules decide who can read or write data.
| In the browser? | Why | |
|---|---|---|
| OpenAI API key | No | Bills your account |
| Stripe sk_ secret key | No | Moves money, reads data |
| Supabase service_role or secret key | No | Bypasses RLS |
| Supabase anon or publishable key | Yes, with RLS | RLS limits what it can do |
| Stripe pk_ key | Yes | Built for browsers |
| Firebase web config | Yes, with rules | Only identifies the project |
Public keys are safe only when rules or limits sit behind them.
Some providers let you restrict a browser key to your site's domain. Anyone can fake the referrer (the site address the browser reports) with a tool like curl, so treat this as a speed bump. Also set a quota or spend cap so a leaked key hits a ceiling.
Test it yourself
Run these only against your own app.
First, search what visitors download. Build the app and grep the output folder (dist/ for Vite, .next/static for Next.js). The pattern covers common secret formats, not every format:
npm run build grep -rEn "sk-|sk_live|sk_test|sb_secret_|service_role|AIza|AKIA|Bearer " dist/
Bad: a line that contains a real secret key. Good: no output. That means none of these patterns matched, not that no secret exists, so also check each VITE_ and NEXT_PUBLIC_ variable you use. Read each match, since some hits are harmless.
Second, watch the traffic. Open your deployed site, open DevTools, go to the Network tab and use the feature. Bad: a request goes to api.openai.com with an Authorization header. Good: it goes to /api/chat and carries no provider key.
Third, call your proxy without logging in. Good is a 401. Bad is a 200 with an answer.
curl -i -X POST https://yourapp.com/api/chat \
-H "Content-Type: application/json" \
-d '{"prompt":"hello"}'Fourth, send the same call about 15 times with a valid session cookie and a short prompt (these calls reach the real API). With the in-memory limiter on one instance, expect ten 200s and then 429s. All 200s means the rate limit isn't working.
Fix prompt you can paste
Give this to Cursor or Claude Code, then read the diff before you merge it. If you want a second look at your repo afterwards, Leakcade can scan it.
Audit this React project for API keys that reach the browser. 1. Search the repo for client-side env reads (import.meta.env.VITE_*, process.env.NEXT_PUBLIC_*) and hard-coded strings that look like keys (sk-, sk_live_, sk_test_, Bearer, service_role, eyJ). Check src/, components/, app/ files marked "use client", committed .env files, git history and any build output folder. 2. For each hit, give me the file and line, the provider, and whether the key may be public (Supabase anon or publishable key, Stripe pk_ key, Firebase web config) or must stay secret (OpenAI, Stripe sk_, Supabase service_role). 3. For every secret key, add a server-side proxy: a Next.js route handler at app/api/<name>/route.ts, or a Supabase Edge Function or Cloudflare Worker if the project already uses one. It must require a signed-in user, rate limit per user, validate and cap the request body, read the key from a server-only env var without a NEXT_PUBLIC_ or VITE_ prefix, and never let the browser choose the upstream URL. 4. Change the React code to call the proxy and send only user input. 5. Never print real key values. Use placeholders, and tell me which env vars to set on my host and which keys to revoke and replace. 6. Finish with every file you changed (path and line) and a short explanation of each change.
Further reading
- Vite docs: Env variables and modes
- Next.js docs: Environment variables
- Supabase docs: Edge Function secrets
- Cloudflare Workers docs: Secrets
- OpenAI docs: Production best practices
FAQ
Can you hide an API key in a React app?
No. React code runs in the visitor's browser, so anything in the bundle is readable. Keep the key on a server and have your React code call that server instead.
Is it safe to put an API key in a Vite .env file?
No. Vite bundles every variable that starts with VITE_ into your client code, so any visitor can read it. Keep the secret on a server.
Is NEXT_PUBLIC_ safe for API keys?
No. Next.js copies NEXT_PUBLIC_ values into the JavaScript sent to the browser. Use a variable without the prefix and read it in a route handler or server component.
Is the Supabase anon key safe to expose in the frontend?
Yes, as long as Row Level Security is enabled and your policies are correct. The service_role and secret keys bypass RLS, so keep them on the server only.
I already pushed my API key to GitHub or deployed it. What now?
Revoke the key at the provider and create a new one. Deleting it from your code doesn't help, because old bundles and git history can still contain it. Then move the new key to a server secret and check your provider's usage page for calls you don't recognize.
Want a second pair of eyes?
We review your whole repo and write a fix prompt for every finding. $19, report within an hour.
Check my repoLeakcade is an automated check. It can miss vulnerabilities and is not a substitute for a professional security audit or pentest. Provided as is. You stay responsible for your code. Where the law allows, our liability is limited to what you paid ($19). Terms