← Back to blog

Are frontend environment variables secret? What VITE_ and NEXT_PUBLIC_ actually do

You moved your API key into a .env file because every tutorial says that keeps it out of your code. Then the browser reported the variable as undefined, so you, or the AI assistant building the app with you, renamed it to VITE_OPENAI_API_KEY. Now everything works. It works because the key is now sitting in plain text inside the JavaScript every visitor downloads.

The short answer to the question in the title is no. A variable with a client prefix is not secret, and the prefix is the reason. This post covers what the build actually does with those variables, how to check your live site in two minutes, which keys are genuinely fine to ship, how to move the rest behind a server, and what to do if a secret has already gone out.

Are frontend environment variables secret?

No. Frontend environment variables are not secret. Any variable with a client prefix such as VITE_, NEXT_PUBLIC_, REACT_APP_ or EXPO_PUBLIC_ is copied into your JavaScript as plain text at build time, and anyone can read it in DevTools. Only unprefixed variables read by server code stay private.

Every major framework says this in its own documentation, usually in a warning box. Vite's guide states that "VITE_* variables should not contain sensitive information such as API keys," because their values are bundled into your source code at build time. Expo's documentation is blunter: values in EXPO_PUBLIC_ variables are visible in plain text in your compiled application, and anyone running the app has access to them. Mobile is not an exception. An APK or IPA can be unpacked with free tools in a few minutes.

The confusion is understandable. A .env file feels like a vault, and it does do one useful job: it keeps values out of the files you commit, as long as it is listed in .gitignore. It says nothing about where those values end up after a build. The .env file is private. The prefix is an instruction to publish.

What happens to a VITE_ or NEXT_PUBLIC_ variable at build time?

At build time, the bundler finds every reference such as import.meta.env.VITE_API_KEY or process.env.NEXT_PUBLIC_API_KEY and replaces it with the literal value from your environment. The shipped file contains the string itself, not a lookup, so the value is public the moment that build is deployed.

The Next.js documentation describes the mechanism directly: it inlines the value into the JavaScript bundle, replacing references with a hard-coded value, and the result is sent to the browser. Vite does the same thing, statically replacing the constants at build time so tree-shaking works. A side effect worth knowing: once built, the app no longer responds to changes in those variables. Changing the value in your hosting dashboard does nothing until you rebuild, which matters when you rotate a key later.

The prefix differs by framework, but the behavior is identical:

FrameworkClient prefixWhere the value ends up
Vite (React, Vue, Svelte)VITE_Inlined into files in dist/assets
Next.jsNEXT_PUBLIC_Inlined into chunks in .next/static
Create React App (deprecated)REACT_APP_Inlined into build/static/js
Expo / React NativeEXPO_PUBLIC_Inlined into the app bundle
Astro, SvelteKitPUBLIC_Inlined into client code

The prefix system is actually a safety feature that works in the other direction. Unprefixed variables are stripped from client code, so a stray DATABASE_URL in your .env cannot leak by accident. Vite enforces this hard enough that it throws an error if you set envPrefix to an empty string, because that would expose every variable you have. The protection only fails when someone adds the prefix to make an error go away, which is exactly the move in the opening paragraph.

The .env file is private. The prefix is a publishing instruction. If a value has VITE_ or NEXT_PUBLIC_ in front of it, treat it as printed on a billboard.

How do you check what your deployed bundle exposes?

To check what your deployed bundle exposes, open your live site, open DevTools, and search all loaded JavaScript for key prefixes such as sk_live_, sk-proj-, AKIA, ghp_ and service_role. Locally, run a production build and search the output folder the same way. Anything you find there, a stranger can find too.

In Chrome, open DevTools and press Ctrl+Shift+F (Cmd+Option+F on a Mac) to search across every loaded source file at once. Minification does not help you here: it shortens variable names, but string values survive untouched. Locally, after a build, one command covers the common formats:

grep -rEo "sk_live_[A-Za-z0-9]+|rk_live_|sk-proj-|sk-ant-|AKIA[0-9A-Z]{16}|ghp_|github_pat_|service_role|sb_secret_" dist .next/static build 2>/dev/null

These are the formats worth knowing on sight:

  • sk_live_ and rk_live_: Stripe secret and restricted keys. These can issue refunds and read customer data.
  • sk-proj- and sk-ant-: OpenAI and Anthropic API keys. Anyone holding one can spend your credits.
  • AKIA followed by 16 characters: an AWS access key ID, usually with its secret key nearby.
  • ghp_ and github_pat_: GitHub personal access tokens, often with write access to your repositories.
  • service_role or sb_secret_: Supabase keys that bypass Row Level Security entirely.

Also check whether your host is serving source maps. A deployed .map file hands out your original, unminified source, comments included. And remember that an obvious search is not the only route to a key. The Stanford and UC Davis paper Keys on Doormats, published at ACM CCS 2026, rendered 10 million websites and verified 1,748 live credentials for 14 service providers, with 62% of JavaScript exposures found only in compiled deployment bundles. Several of the organizations involved were global banks and infrastructure providers, so this is not only an indie problem.

Which API keys are safe to put in frontend code?

API keys designed to be public are safe in frontend code: a Stripe publishable key, a Supabase anon or publishable key, a Firebase web config, and a referrer-restricted Maps key. They identify your project, and authorization happens elsewhere. Keys that spend money or bypass access rules, such as OpenAI, Stripe secret, AWS or service_role keys, never belong there.

KeyFine in the bundle?Why
Stripe publishable (pk_live_)YesCan only create tokens and start checkout
Stripe secret (sk_live_)NeverFull account access: charges, refunds, customers
Supabase anon / publishableYes, with RLS onGrants exactly what your policies allow
Supabase service_role / secretNeverBypasses Row Level Security
Firebase web config apiKeyYes, if restrictedIdentifies the project; rules do the authorizing
Gemini, OpenAI, Anthropic API keysNeverBilled per request to you
AWS access keys, GitHub tokensNeverAct as you, with your permissions

"Public by design" still comes with a condition. A Supabase anon key is only harmless when Row Level Security is enabled and correct on every exposed table, which is the subject of whether the Supabase anon key is safe to expose. Firebase works the same way: Google's documentation says Firebase API keys only identify your project and do not need to be treated as secrets, but the security rules behind them do all the work, and those rules start out open more often than people expect. The same page carves out one firm exception: a Gemini Developer API key should never be included in your code or config files.

When you are unsure about a key, ask one question. If a stranger copied this value, could they run up my bill, read another user's data, or act as me? If the answer to any part is yes, the key belongs on a server.

How do you hide an API key in a React or Vite app?

You cannot hide an API key inside a React or Vite app, because everything the browser runs can be downloaded. The fix is to move the call itself: put the key in a server function, such as a Next.js route handler, a Netlify or Vercel function, or a Cloudflare Worker, and have the frontend call that endpoint instead.

Obfuscation, base64 encoding and splitting the key into pieces do not count as hiding it. The browser has to reassemble the real value to use it, so anyone can set a breakpoint and read it at that moment. The only key a browser cannot leak is one it never receives.

In Next.js the server side is built in. An unprefixed variable read inside a route handler never reaches the client:

// app/api/summarize/route.js (runs on the server only)
export async function POST(req) {
  const { text } = await req.json()
  if (!text || text.length > 4000) {
    return Response.json({ error: 'Invalid input' }, { status: 400 })
  }
  const res = await fetch('https://api.example-ai.com/v1/generate', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.AI_API_KEY}`, // no NEXT_PUBLIC_ prefix
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ input: text }),
  })
  return Response.json(await res.json())
}

Two extra guards are worth adding in Next.js. Import the server-only package at the top of any module that touches secrets, and the build fails if that module is ever pulled into a Client Component. And never pass a secret as a prop from a Server Component to a Client Component, because props are serialized into the page.

A plain Vite single-page app has no server at all, so the function has to live somewhere else: your host's serverless functions, a Worker, or a small backend. Supabase Edge Functions work too, reading the key with Deno.env.get.

Moving the key solves one problem and creates a smaller one. Your proxy endpoint is now public, and anyone can call it in a loop and spend your credits through it. Give it the same discipline you would give any paid endpoint:

  • Require a signed-in user, and check the session on the server.
  • Rate limit per user and per IP address.
  • Cap input size, as the example above does, so one request cannot cost much.
  • Set a hard monthly spend limit in the provider's dashboard.

An unprotected proxy is the classic route to a surprise invoice, covered in detail in how one bug can run up a five-figure bill.

What should you do if a secret already shipped in your bundle?

If a secret already shipped in your bundle, rotate it at the provider first, before touching any code. Deleting it from source does not remove copies already downloaded, cached by a CDN, or archived by crawlers. Then check the provider's usage logs for activity you did not cause, move the call server-side, redeploy, and scrub the old value from git history.

The order matters. A redeploy feels like the fix, but the old bundle may still be reachable: many hosts keep every previous deployment live at its own URL, and archive services capture JavaScript files too. The Keys on Doormats researchers found exposed credentials that stayed public for months and sometimes years, which tells you how rarely anyone goes back to check. Revoking the key is the only step that makes every existing copy worthless at once.

  1. Revoke and reissue the key in the provider's dashboard.
  2. Read the usage and billing logs for the period the key was exposed. Unexpected spend, new API users, or calls from unfamiliar regions are the signs.
  3. Set spending caps on the new key before you use it.
  4. Move the call server-side and store the new key without a client prefix.
  5. Rebuild and redeploy, then delete or protect old preview deployments.
  6. Remove the value from git history if it was ever committed. A key deleted in a later commit is still in the earlier one, which is the trap described in how git history keeps leaking deleted keys.

Why do AI coding tools put secrets in VITE_ variables?

AI coding tools put secrets in VITE_ or NEXT_PUBLIC_ variables because that is the fastest fix for the error they were asked to solve. An unprefixed variable reads as undefined in the browser, and adding the prefix makes it appear. The app works, the demo passes, and the key is now public.

The assistant is optimizing for the symptom you described, and "make the API call work from the component" has a one-line answer that happens to be the wrong one. Building the proxy is the correct answer, and it means a new file, a new endpoint and a different data flow, which is more than a quick fix usually gets. This is the general pattern behind the hidden security cost of vibe coding: code that runs and looks plausible, with the vulnerability sitting in the part nobody tested.

The scale is real. In December 2025, Intruder scanned roughly 5 million applications and found more than 42,000 exposed tokens across 334 secret types in front-end JavaScript, including 688 GitHub and GitLab repository tokens. Bundles are a bigger leak surface than most teams assume, and the wider set of places keys hide is covered in how indie apps leak their API keys.

You can head most of this off with one line in your project instructions or your first prompt: "Secret API keys are only read in server code. Never use a VITE_ or NEXT_PUBLIC_ prefix for a key, and never call a paid API from the browser." Assistants follow a stated constraint far more reliably than they infer one.

Catching it before launch

The manual check above takes a few minutes and is worth doing on every deploy that touches configuration. If you would rather have something read your source and hand you a ranked list, the free IOnclad browser scanner runs a 167-rule subset entirely in your browser, with no signup and no email wall. Among those rules are checks for secrets in NEXT_PUBLIC_ variables, LLM API keys in client-side code, and well-known key formats such as Stripe live secret keys. Your code stays in the tab.

The IOnclad desktop app goes further, with 24 scanners and 500+ checks covering secrets and git history, dependencies, auth, and a denial-of-wallet check for exactly the unprotected proxy described above. It returns one Ship-It verdict with the file, line and a fix for each finding. No scanner can promise an app is secure. What it can do is make sure the key with a VITE_ in front of it gets noticed before your first real user opens DevTools.

This post is part of our app security guides for indie developers.

Free, in your browser
IOnclad browser scanner

Check your app for exposed keys and the issues that stop a launch. No signup, no email wall, and your code never leaves the tab.

→
Keep reading
IOnclad Is the Supabase Anon Key Safe to Expose in Your App? Read → IOnclad Hardcoded Secrets: How Indie Apps Leak Their API Keys Read → IOnclad Denial of Wallet: How One Bug Can Run Up a $10,000 Bill Read →
Reading us on Google?
Add The IOn Project as a preferred source

One click on Google’s preferences page, and our articles show up more often in your Top Stories, AI Overviews, and AI Mode.

→