Environment variables and API keys: keeping secrets out of your code

Which keys are safe in the browser and which must stay on a server, how .env files and host settings work, the framework prefixes that quietly make a secret public, and what to do in the first hour after a key leaks.

Floot ·

Key Takeaways

  • Anything sent to the browser is public. If a value is in your frontend bundle, any visitor can read it with the browser’s developer tools. Secret keys must only be used in backend code.
  • Some framework prefixes publish values on purpose. Vite exposes variables starting with VITE_ and Next.js inlines variables starting with NEXT_PUBLIC_ into the JavaScript sent to browsers. Never give a secret one of these prefixes.
  • Some keys are designed to be public, most aren’t. Stripe’s publishable key and Supabase’s publishable key are meant for frontend code. Stripe secret and restricted keys, Supabase secret keys and AI provider keys are not.
  • .env files are for your laptop; host settings are for production. Keep .env files out of git, commit a .env.example with blank values, and enter real production values in your host’s environment settings.
  • A leaked key must be revoked, not just deleted from the code. Removing the key in a new commit leaves it in git history and in anyone’s copy. Revoke or rotate it at the provider first, then clean up.

Quick-Start Prioritization Framework

KeySafe in frontend code?Where it belongs
Stripe publishable key (pk_...)Yes, by designFrontend is fine
Stripe secret or restricted key (sk_..., rk_...)NoBackend only, in host secrets
Supabase publishable or legacy anon keyYes, if Row Level Security is onFrontend is fine
Supabase secret or legacy service_role keyNo: it bypasses Row Level SecurityBackend only
OpenAI, Anthropic or other AI API keyNoBackend only, behind your own endpoint
Database connection string with passwordNoBackend only

AI coding tools are very good at making a feature work, and the quickest way to make an API call work is to paste the key right into the code. That is how paid OpenAI keys, Stripe secret keys and database admin passwords end up in public GitHub repositories and in JavaScript files anyone can download. This guide explains what environment variables are, the one rule that decides whether a key is safe, how to set secrets up locally and on your host, and the exact steps to take if one leaks. It is written for people who built an app with Claude Code, Cursor, Codex or ChatGPT and want to check that their app isn’t giving its keys away.

What an environment variable is, and why it exists

An environment variable is a named value that a program reads from the system it runs on rather than from its own source code. Your code says process.env.OPENAI_API_KEY; the actual key lives in the environment of whatever machine runs the code.

That separation does two jobs. It keeps secrets out of the code, so you can share the code, commit it and show it to an AI assistant without handing over the keys. And it lets the same code run with different settings in different places: a test Stripe key on your laptop, a live one in production.

Why code is the wrong place for a secret

Code gets copied. It goes into git, then into GitHub, into backups, into screenshots, into the context of AI tools, into a contractor’s laptop. Git keeps every version forever, so a key committed once and deleted the next day is still in the history. OpenAI’s production guidance is direct: avoid exposing API keys in your code or in public repositories, and load them from environment variables or a secret manager instead.

What counts as a secret

Anything that grants access or costs money: API keys for paid services, database passwords and connection strings, webhook signing secrets, OAuth client secrets, the key your app uses to sign session cookies, and SMTP passwords. Configuration that isn’t sensitive, like a feature flag or your app’s public URL, can still live in environment variables for convenience, but it doesn’t need the same care.

The one rule: the browser can read everything you send it

A modern web app is two programs. The frontend runs in each visitor’s browser. The backend runs on a server or in serverless functions you control. Anything the frontend uses has to be downloaded to the visitor’s computer, where they can read it.

This is where AI-built apps most often go wrong. If the agent wrote a chat feature where the browser calls the OpenAI API directly, the key is in the downloaded JavaScript, no matter how it got there.

The prefixes that publish a value

Frontend frameworks protect you by default and then give you a deliberate way to expose a value. Vite only exposes variables that start with VITE_, and its documentation warns that VITE_ variables should not contain sensitive information such as API keys. Next.js keeps variables on the server unless they start with NEXT_PUBLIC_, in which case it inlines the value into the JavaScript bundle at build time.

So if you see VITE_OPENAI_API_KEY or NEXT_PUBLIC_STRIPE_SECRET_KEY in your project, the key is public. The fix isn’t renaming it; it’s moving the call to the backend and then rotating the key, because every build that shipped already contains it.

How to check your own app in two minutes

Open your published app, open developer tools, go to the Sources (or Debugger) tab, and search all files for sk_, sk-, secret, service_role and the first few characters of each key you use. Then open the Network tab, use the feature that calls a paid API, and look at which domain the request goes to. If the browser talks directly to api.openai.com or api.anthropic.com with your key in a header, it’s exposed.

Keys that are public by design, and keys that never are

Not every key in a frontend is a mistake. Some services issue two kinds of key precisely so that one can be public.

Stripe

Stripe’s documentation lists the publishable key (pk_...) as safe to expose: it can identify your account and create payment tokens, but it can’t create charges or read account data. Secret keys (sk_...) and restricted keys (rk_...) are not safe to expose. Stripe recommends restricted keys with only the permissions your integration needs, and keys start with pk_test_ or sk_test_ in a sandbox and pk_live_ or sk_live_ in live mode.

Supabase

Supabase’s publishable key (sb_publishable_..., or the older anon key) is meant for browsers and mobile apps. It is only safe because Row Level Security limits what it can reach, so every table needs RLS turned on and sensible policies. The secret key (sb_secret_..., or the older service_role key) bypasses Row Level Security entirely and belongs only in backend code. Supabase says the legacy keys are planned for deprecation by the end of 2026.

AI model providers

OpenAI, Anthropic, Google and similar providers bill you for every call made with your key. There is no public variant. A leaked key can run up charges until you notice, so it must sit on the backend, and your backend endpoint should check that the caller is signed in and limit how often they can call it.

Setting up secrets on your own computer

Locally, environment variables usually come from a .env file in the project folder. Frameworks load it automatically when you run the development server.

The files and which wins

Vite loads .env, then .env.local, then mode-specific files like .env.production and .env.production.local, with more specific files taking priority and variables already set in your shell taking priority over all of them. Next.js looks in process.env first, then .env.$(NODE_ENV).local, .env.local, .env.$(NODE_ENV) and finally .env. In both, files ending in .local are meant to stay on your machine.

Keep them out of git

Check that your .gitignore contains .env and *.local (or .env*.local). Next.js’s create-next-app template adds .env files to .gitignore for you; other starters may not. Then commit a .env.example file listing every variable name with an empty or fake value, so you and your AI assistant know what the app needs without seeing real values.

  • OPENAI_API_KEY=
  • STRIPE_SECRET_KEY=sk_test_replace_me
  • DATABASE_URL=postgres://user:password@localhost:5432/app
  • NEXT_PUBLIC_APP_URL=http://localhost:3000

Be careful what you paste into an AI chat

Anything you paste into a chat is sent to the AI provider and may stay in your conversation history. Ask the agent to read variable names from .env.example and to reference process.env, and put the real values in the file or host settings yourself. Stripe’s guidance makes the same point more broadly: don’t share keys over email, chat or other unencrypted channels.

Setting secrets in production

Your .env file doesn’t get deployed (and shouldn’t). Production values go into your host’s environment settings, where they are injected when the app builds or runs.

One set per environment

Hosts usually separate environments. Vercel, for example, lets you scope each variable to Production, Preview or Development, encrypts values at rest, and offers a Secret type that is write-only after saving. Use test keys for Preview and Development and live keys only in Production, so a half-finished branch can’t charge real cards.

Changes need a new deploy

Vercel’s documentation notes that environment variable changes apply only to new deployments; the running version keeps the values it was built with. Most hosts behave the same way. If you rotate a key and the app still fails, redeploy. Remember too that public variables like NEXT_PUBLIC_ ones are frozen into the bundle at build time.

Give each key the least power it needs

OWASP’s Secrets Management Cheat Sheet recommends least privilege: each secret should be readable only by the code that needs it and should allow only what that code does. Concretely, use a Stripe restricted key instead of the full secret key, a database user that can’t drop tables for the app, and separate keys per app so one leak doesn’t expose everything.

Worked example: moving an AI key off the frontend

Say your app has a “summarize my notes” button, and the agent wrote it so the browser calls the OpenAI API directly with VITE_OPENAI_API_KEY. Here is the fix, in the order you would do it.

1. Add a backend endpoint

Create a server route, for example POST /api/summarize, that receives the notes text, calls the AI provider using process.env.OPENAI_API_KEY (no public prefix) and returns the summary. The browser now calls your endpoint, never the provider.

2. Protect the endpoint

An open endpoint that calls a paid API is almost as bad as a leaked key, because anyone can call it. Require a signed-in user, cap the input length, and limit calls per user per hour. Log usage so you can see a spike.

3. Rotate the old key

The old key shipped in every previous build, so treat it as leaked. Create a new key at the provider, put it in the host’s secret settings, redeploy, and revoke the old key. Then set a monthly spending limit at the provider if it offers one.

When a key leaks: the first hour

OWASP’s guidance is that exposed keys should be revoked immediately. The order matters, because cleaning the code first leaves the key working in the meantime.

Revoke or rotate at the provider

Go to the service’s dashboard and revoke the key or rotate it. Stripe’s rotate option, for instance, revokes the old key and creates a replacement, with an optional grace period of up to 7 days in which both work; for a key you know is compromised, choose to expire it now.

Deploy the new key, then check the damage

Put the replacement in your host’s settings and redeploy. Then read the provider’s usage or request logs for the period the key was exposed: unexpected charges, new resources, data exports. For a database password, check for unfamiliar connections and changed rows.

Clean up and prevent the next one

Remove the key from code, and if the repository is public, accept that rewriting history won’t un-publish it; that’s why revocation comes first. GitHub’s push protection blocks pushes that contain recognized secrets. It is on by default for pushes to public repositories from user accounts, and repository administrators can turn it on for a repository.

Common mistakes

These come up again and again in apps built quickly with AI help.

Using a public prefix to “fix” an undefined variable

The frontend says the key is undefined, so the agent renames it to VITE_ or NEXT_PUBLIC_ and it works. It works because the key is now public. Move the call to the backend instead.

Committing .env once

Adding .env to .gitignore after it was committed doesn’t remove it from history. If it ever reached a shared repository, rotate every key in it.

Same keys everywhere

Live payment keys on a laptop or in preview deployments mean a test can charge a real card, and a leaked development machine exposes production. Keep test and live keys separate.

Secrets in logs and error messages

Logging the full request headers or the whole environment for debugging writes keys into log storage that more people can read. Log variable names, not values.

Trusting the publishable key without the rules behind it

A Supabase publishable key is only safe with Row Level Security on every exposed table. Supabase’s own documentation warns that a table without RLS in an exposed schema is readable and writable by any role with a grant on it.

If you’d rather not do this yourself

If you’d rather not wire keys between dashboards, Floot keeps an app’s backend endpoints, database and settings in one project, and its MCP server lets Claude Code, Codex, Cursor, ChatGPT or Claude set the app up for you, so secrets stay in backend code rather than being pasted around.

Frequently Asked Questions

Is it safe to put an API key in a .env file?

Yes, for local development, as long as the file is listed in .gitignore and the key isn’t given a public prefix like VITE_ or NEXT_PUBLIC_. In production, use your host’s environment or secret settings instead of a file.

Can people see my environment variables in the browser?

They can see any value your frontend uses, including variables with public prefixes, because those are built into the JavaScript the browser downloads. Variables used only by backend code stay on the server.

Is the Supabase anon or publishable key a secret?

No. Supabase designs it to be used in browsers and mobile apps. It is safe only if Row Level Security is enabled with correct policies on every exposed table. The secret or service_role key, which bypasses those policies, must never reach the frontend.

I pushed a key to GitHub and deleted it right away. Am I safe?

Assume not. The key is still in the repository history and may already have been copied. Revoke or rotate it at the provider, deploy the new one, and check the provider’s logs for use you don’t recognize.

Why does my app still use the old key after I changed it?

Most hosts apply environment variable changes only to new deployments. Redeploy the app. Public variables are also baked into the frontend at build time, so they need a rebuild too.

Do I need a dedicated secrets manager?

For a small app, your host’s encrypted environment settings are usually enough. Dedicated secret managers become useful when several services and people share secrets or when you need automatic rotation and audit logs.

Sources