LEAKCADE

How to hide API key in React and keep it off the browser

Updated 8 min read

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.

Where the key livesTwo paths. Wrong: the React app calls the API directly, the key sits in the bundle, any visitor reads it in DevTools and spends your quota. Right: the browser sends only the prompt, your server checks login, adds the key and calls the API.WRONG PATH1React calls theAPI directly2Key is inlinedin the bundle3Visitor reads itin DevTools4Strangers spendyour quotaRIGHT PATH1React sends onlythe prompt2Your serverchecks login3Server adds key,calls the API4Browser neversees the keyWRONG PATH1React calls the API directly2Key is inlined in the bundle3Visitor reads it in DevTools4Strangers spend your quotaRIGHT PATH1React sends only the prompt2Your server checks login3Server adds key, calls the API4Browser never sees the key
The key should only ever exist on a server.

How people find an API key in your bundle

Anyone can find a key without hacking anything:

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:

  1. Check who is calling.
  2. Limit how often they call.
  3. Validate the input.
  4. Add the key from a server secret.
  5. 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.

BEFOREWrong: key in the browser
// 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 }),
})
AFTERRight: key on the server
// 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 browser sends only the prompt. The route checks the user, limits the rate and adds the key.

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.

  1. Find every key the browser can see

    Search your repo for VITE_, NEXT_PUBLIC_ and strings like sk- or Bearer. Then build the app and search the output folder too, because the bundle is what visitors get.

  2. 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.

  3. Store the new key as a server secret

    Use .env.local with no public prefix locally and set the same variable in your host's settings. For Supabase use supabase secrets set --env-file, for Cloudflare wrangler secret put. Add every env file to .gitignore.

  4. 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.

  5. 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.

  6. 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 keyNoBills your account
Stripe sk_ secret keyNoMoves money, reads data
Supabase service_role or secret keyNoBypasses RLS
Supabase anon or publishable keyYes, with RLSRLS limits what it can do
Stripe pk_ keyYesBuilt for browsers
Firebase web configYes, with rulesOnly 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.

FIX PROMPT
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

  1. Vite docs: Env variables and modes
  2. Next.js docs: Environment variables
  3. Supabase docs: Edge Function secrets
  4. Cloudflare Workers docs: Secrets
  5. 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 repo

Leakcade 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

Patch Notes by Leakcade

A short weekly email: what changed in AI coding tools, one real security mistake, one prompt you can reuse.