How to Secure an API Key in Your Frontend (the Right Way)

Learn how to secure api key in frontend code: server-side proxies, correct env vars, key scoping, and rotation, with a copy-paste Next.js proxy example.

Barret9 min read

You've read that you shouldn't put an API key in your frontend. Fine. But your app needs to call OpenAI, or Stripe, or some other paid API, and the code that calls it has to live somewhere. So what do you actually do, line by line, file by file?

This post skips the theory and gets to the mechanics. For the fuller explainer on which keys are dangerous versus which (like a Supabase anon key or Stripe pk_live) are public by design, read the exposed secrets and API keys guide first. Here, we're assuming your key is the dangerous kind (secret, metered, or account-level) and you want the concrete fix.

There are really only five moves. Do the first two whenever you can, use the rest as backstops.

⚡ TL;DR

  • The only real fix is to never send the secret key to the browser at all: proxy the call through your own backend instead.
  • NEXT_PUBLIC_ and VITE_ prefixed env vars are not "hidden." They're compiled straight into the JavaScript bundle every visitor downloads. Naming a secret NEXT_PUBLIC_OPENAI_KEY ships it.
  • If a key truly must run in the browser (a maps widget, an analytics SDK), restrict it by domain/referrer and by API scope instead of pretending it's secret.
  • Rotation and spend alerts are the safety net, not the plan. Build the proxy first.

Move 1: never ship the secret to the browser

This is the whole game. A key that never reaches client-side code can't be extracted from client-side code, no matter how much minification or "clever" string-splitting you apply. Anything the browser downloads and runs, a visitor can read with dev tools and a text search.

So the rule is structural, not stylistic: the secret key lives only where you control the runtime (a server, a serverless function, an edge function). It is never imported into a React component, never referenced in client-side JavaScript, and never assigned to a variable that a bundler will include in the browser build.

Move 2: proxy the call through your own backend

This implements Move 1. Instead of the browser calling the third-party API directly with the key attached, it calls a route on your server, and your server calls the third-party API using the key it holds privately.

Before: the key ships to every visitor.

// app/page.tsx (client component) — DO NOT DO THIS
async function askAI(prompt) {
  const res = await fetch("https://api.openai.com/v1/chat/completions", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      // this header is visible in the Network tab and in the bundled JS
      Authorization: `Bearer sk-proj-abc123...`,
    },
    body: JSON.stringify({
      model: "gpt-4o-mini",
      messages: [{ role: "user", content: prompt }],
    }),
  });
  return res.json();
}

Open dev tools, hit Network, and the key is right there on every request. Anyone who saves the page's JS can grep it out of the bundle too.

After: a thin server-side proxy holds the key.

// app/api/ask-ai/route.js (Next.js API route — runs on the server only)
export async function POST(request) {
  const { prompt } = await request.json();

  const upstream = await fetch("https://api.openai.com/v1/chat/completions", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      // OPENAI_API_KEY has no NEXT_PUBLIC_ prefix, so it never reaches the client bundle
      Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
    },
    body: JSON.stringify({
      model: "gpt-4o-mini",
      messages: [{ role: "user", content: prompt }],
    }),
  });

  if (!upstream.ok) {
    return Response.json({ error: "Upstream request failed" }, { status: 502 });
  }

  const data = await upstream.json();
  return Response.json({ reply: data.choices[0].message.content });
}
// app/page.tsx (client component) — calls your own route, no key attached
async function askAI(prompt) {
  const res = await fetch("/api/ask-ai", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ prompt }),
  });
  return res.json();
}

The browser now only talks to /api/ask-ai on your own domain. It never sees the OpenAI key or the upstream URL, and can't extract anything more than the prompt-and-response pairs your route returns. On a plain Node/Express backend, a serverless function, or a Supabase edge function, the shape is identical: client calls your endpoint, your endpoint holds the secret.

This costs a little latency and one more file. It buys the only guarantee that matters: the key cannot leak through the frontend, because it was never in the frontend.

Move 3: use environment variables correctly

This is where most AI-assisted builds go wrong, because "environment variable" sounds like it means "hidden," and it doesn't, not automatically.

Frameworks split env vars into two categories:

  • Server-only: a plain OPENAI_API_KEY=sk-... in your .env file. Only code running on the server (an API route, a server component, a backend process) reads it. It never gets bundled for the browser. This is where every secret key belongs.
  • Client-exposed: anything prefixed NEXT_PUBLIC_ (Next.js), VITE_ (Vite), or REACT_APP_ (Create React App). These prefixes are a deliberate instruction to the build tool: bake this value into the JavaScript bundle. It does exactly that, every time.

The mistake is naming a secret with one of those prefixes because it's convenient in a client component: NEXT_PUBLIC_STRIPE_SECRET_KEY, VITE_OPENAI_KEY. The "env var" framing then becomes a false sense of security. It's not encrypted or access-controlled, it's compiled into a static file served to anyone who loads the page.

# .env.local — correct split

# server-only: read by API routes / server components, never bundled
OPENAI_API_KEY=sk-proj-abc123...
STRIPE_SECRET_KEY=sk_live_abc123...

# client-exposed on purpose: identifiers, not secrets
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_abc123...
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOi...

The test to run before naming any env var: if this value ended up in view-source on my production site tomorrow, would that be fine? If yes, prefix it for the client. If no, it stays unprefixed and is only ever read inside server-side code, per Move 2.

⚠️ Watch out

.env.local (or .env) not being committed to git protects your local secrets from GitHub. It does nothing to stop a NEXT_PUBLIC_-prefixed value from shipping to every visitor once you deploy. Those are two separate problems with two separate fixes.

How to check your own app

Ten minutes, browser and repo only:

  1. Open your deployed site, then dev tools → Sources or Network. Search the loaded JS for sk-, sk_live_, sk-ant-, or any provider key format you use. If it's in there, it's public: proxying (Move 2) is the fix, not a config tweak.
  2. Grep your codebase for the client-exposed prefixes. Run grep -rn "NEXT_PUBLIC_\|VITE_\|REACT_APP_" .env* src/ (adjust paths) and check every match against the "fine in view-source" test above.
  3. Check your API routes actually exist. If a client component is calling a third-party API URL directly instead of your own /api/... route, that's the pattern from Move 1's "before" example. Move it server-side.
  4. List every key that has to touch the browser at all (maps widgets, embedded analytics, client SDKs) and confirm each one is restricted per Move 4 below, not just "not a big deal."

Not sure which of your keys are actually exposed?

Run a free, read-only scan of your live app: no install, results in under a minute. Is My Site Hackable? tells you which keys are real leaks and which are fine.

Scan my app free →

Move 4: scope and restrict keys that truly must be client-side

Some keys can't be proxied, because the whole point is that they run in the user's browser: a Google Maps JavaScript SDK key, a client-side analytics token. When a proxy isn't practical, the fallback is to make the key useless to anyone but your site.

For a Google Maps API key, in the Google Cloud Console:

  • Application restrictions → HTTP referrers: allow only your production domain (and localhost for dev). A request from any other site gets rejected even with the key attached.
  • API restrictions: limit the key to only the specific APIs it needs (e.g., Maps JavaScript API), so even a copied key can't be used to call unrelated, possibly billable APIs.

The principle recurs elsewhere: Firebase Security Rules scope what the public web config can touch; Stripe's publishable key is architecturally powerless rather than restricted after the fact. If a key must be visible, strip it of any authority beyond exactly what your frontend needs.

This is a backstop, not a substitute for Move 2. Domain restrictions can be bypassed by a spoofed referrer sent from a server, and they don't help at all against a key with real account authority. A secret key belongs behind a proxy, never just "restricted."

Move 5: rotate and monitor spend as the last line

Even with a proxy in place, keep a safety net for the day something still goes wrong: a misconfigured env var, an old key that leaked before you fixed it, a contractor's laptop.

  • Set hard spend caps with your provider (OpenAI, Anthropic, and most paid APIs support usage limits or budget alerts). A leaked key with a $20 cap is a bad afternoon, not a bad month.
  • Rotate on any suspicion. If a key showed up anywhere it shouldn't have, generate a new one and revoke the old one immediately. It's minutes of work and it's the only step that actually neutralizes an exposed key.
  • Watch provider dashboards for spikes. A sudden jump in API calls from an unfamiliar pattern is often the first sign a key is being used somewhere you didn't put it.

The full rotation playbook, in order, is in a key just leaked: rotate, audit, monitor. If you want to understand how attackers actually pull keys out of a bundle in the first place, see how attackers extract secrets from JS bundles.

FAQ

Can I just obfuscate or split the key string so scanners don't catch it?

No. The browser has to reassemble and use the real key at runtime, so anyone can too. Obfuscation only slows down automated detection; it doesn't make the key less exposed. The only fix is to keep the key off the client entirely.

Does using a CDN or edge function count as "server-side"?

Yes, as long as the code runs somewhere the client can't read its source or environment. Serverless functions, edge functions, and traditional backends all qualify. Client-side JavaScript that merely runs "close" to the user, like a browser extension, does not.

My app is a static site with no backend. How do I proxy anything?

You still need one small piece of server-side compute, but not a full backend. A single serverless function (Vercel Function, Netlify Function, Cloudflare Worker) is enough to hold the key and forward the request. Most static-site hosts support this directly, often free at low volume.

Is it safe to put the API key in a Next.js server component?

Yes, as long as it never passes the key value down as a prop to a client component and the env var isn't NEXT_PUBLIC_-prefixed. Server components run only on the server, so a plain process.env.OPENAI_API_KEY reference there is exactly the pattern in Move 2.

The bottom line

Securing an API key in your frontend isn't really a frontend problem. It's an architecture decision to keep secret keys off the client entirely and let your own backend make the calls that need them. Get the proxy and the env var split right, and the rest (scoping, rotation, spend caps) is just insurance for the day something slips through. An attacker can find a misplaced key in your bundle in minutes; the fix here takes one small route file, and it holds on every deploy after.

Find your gaps before an attacker does.

Is My Site Hackable? scans your deployed app for the exact issues in this article (exposed keys, missing RLS, open buckets) and tells you what's real and what's a false alarm.

Run a free scan →