Environment Variables & API Keys

Half of key handling is one idea: some keys are meant to be seen and some are credentials, and the whole game is never letting a credential reach a place the public can read it. That place is usually a browser bundle, a git commit, or an AI chat log.

The mistake in one sentence: a secret key ends up somewhere the public can read it (a browser bundle, a git commit, or an AI chat transcript) because it was treated like configuration instead of a credential.

Paste into your AI chat

Copy the entire guide as markdown to use as context in Cursor, Claude, ChatGPT, or any AI tool.

Two kinds of key

Almost everything below comes down to never confusing these two.

Publishable / public

Designed to be seen. Stripe’s pk_..., a Supabase anon key, a Maps browser key. They identify your project but can’t do damage alone, because the real permissions live behind server-side rules. Safe in the browser.

Secret

Credentials. Stripe’s sk_..., a service-role key, a database URL, a Brevo or OpenAI key. Anyone holding one can act as you. Never in the browser, a public repo, or a chat log.

The test is simple. Ask: “if a stranger had this string, could they do something I’d have to clean up?” When the answer is yes, it’s a secret. Treat the two categories differently at every step that follows.

Common ways a key leaks

1. A secret wearing a public prefix

A VITE_ or NEXT_PUBLIC_ prefix on a secret key. The framework bakes it into the JavaScript every visitor downloads.

2. .env committed to a repo

The .env file gets added before it is gitignored, or a key is pasted straight into source. Now it lives in the history forever.

3. An AI agent printed it into the chat

You ask the agent why something won't connect, and it cats .env to compare. Your live keys are now in the transcript and its logs.

4. Hardcoded straight into the source

const key = 'sk_live_...' pasted inline to make something work quickly, then forgotten and shipped.

Why it’s dangerous

One leaked secret is a working copy of your account.

  • Your account, in a stranger’s hands

    A leaked secret key lets someone act as you: charge or refund cards, read and delete every row in your database, send email from your domain, or call any API you’ve authorised.

  • Bots find exposed keys in minutes

    Automated scanners watch public commits and shipped bundles continuously. The gap between “pushed” and “abused” is minutes, not days, so there is no safe window to quietly fix it.

  • You pay the bill

    A leaked metered key (OpenAI, a cloud provider, an SMS gateway) becomes someone else’s free resource on your invoice, and you often find out from the charge, not the alert.

  • Deleting doesn’t undo it

    A key in git history or a shipped bundle stays readable after you remove it from your files. Only rotating or revoking the key at the provider actually kills the leaked value.

Where keys actually go

Three places depending on where the code runs. Never the source you commit.

Locally, in a .env file at the project root, gitignored before you paste a real value. Commit a keyless .env.example so a teammate knows what’s needed without anything leaking.

In production, in the host’s Environment Variables panel (Vercel, Netlify, Railway). You paste the value once and the platform injects it at build and runtime. It never sits in your repo.

On a VPS, as real environment variables via your process manager (a systemd Environment=, a pm2 ecosystem file, a Docker secret) or a .env on the server, outside the web root and outside git.

bash
# .gitignore  — keep every real secret out of git
.env
.env.*
!.env.example

# .env  — real values, NEVER committed
STRIPE_SECRET_KEY=sk_live_51Abc...        # secret, server only
SUPABASE_SERVICE_ROLE_KEY=eyJhbGci...     # secret, server only
VITE_SUPABASE_ANON_KEY=eyJhbGci...        # publishable, safe in the browser

# .env.example  — committed, so teammates know the shape without the secrets
STRIPE_SECRET_KEY=
SUPABASE_SERVICE_ROLE_KEY=
VITE_SUPABASE_ANON_KEY=

The prefix that makes a key public

Front-end frameworks hide most environment variables from the browser on purpose. Only variables with a special prefix get bundled into the JavaScript that ships to the user: Vite exposes anything starting with VITE_, and Next.js exposes anything starting with NEXT_PUBLIC_. That prefix is a deliberate instruction to bake the value into the public bundle, so it becomes readable with View Source. The prefix is safe for publishable keys and a disaster for secret ones. It doesn’t create the danger, it just does exactly what it says on the tin.

bash
# SAFE — a publishable key is meant to be seen. Prefix it so the client can read it.
VITE_STRIPE_PUBLISHABLE_KEY=pk_live_51Abc...
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGci...

# DISASTER — the VITE_/NEXT_PUBLIC_ prefix ships this straight to the browser.
# Every visitor can read it with View Source. This hands over your account.
VITE_STRIPE_SECRET_KEY=sk_live_51Abc...        # <-- never
NEXT_PUBLIC_SERVICE_ROLE_KEY=eyJhbGci...        # <-- never

# Secrets stay UNPREFIXED and are read only on the server:
STRIPE_SECRET_KEY=sk_live_51Abc...              # process.env.STRIPE_SECRET_KEY
Next.js wrinkle: NEXT_PUBLIC_ values are inlined at build time, so they’re baked into the deployed output. Changing one means a full rebuild, not just an env-var edit, and the old value stays frozen in any bundle you already deployed.

Check your own bundle

Build the app and grep the shipped output for anything secret-shaped.

bash
# Build, then grep the shipped output for anything that looks like a secret.

# Vite
npm run build && grep -rE "sk_live|sk_test|service_role|-----BEGIN|xox[baprs]-" dist/

# Next.js
npm run build && grep -rE "sk_live|sk_test|service_role|-----BEGIN|xox[baprs]-" .next/

# Empty result: no secret reached the client.
# Any match: that key is public. Rotate it, then move it server-side.

An empty result means no secret reached the client. A match means that key is compromised the moment the build goes live. Rotate it, then move the logic that uses it to the server.

Vercel’s “Sensitive” checkbox

A write-once flag: the platform uses the value but never shows it back to you.

Marking a variable Sensitive makes it write-only. Vercel still uses it at build and runtime, but it will never reveal the value again in the dashboard, the CLI, or the API. Even you can only overwrite it, not read it.

Turn it on for true secrets

A Stripe secret key, a service-role key, an API token: things you set once and never need to eyeball again. Safe from a shoulder-surfer, a screen-share, or a hijacked dashboard session, and you’d rotate it rather than read it anyway.

Leave it off for readable config

A list of admin email addresses, a FROM address, a feature-flag string: anything you’ll want to audit by eye or edit later. Sensitive mode just locks you out of your own config and leaves you guessing the current value.

The deciding question is whether you’ll ever need to read the value back. Credentials: no, mark them sensitive. Human-readable configuration: yes, leave it visible.

Rotate keys deliberately

A key isn’t a set-and-forget setting. It’s a credential with a lifespan.

Rotate on exposure

The instant a secret touches a bundle, commit, log, screenshot, or chat, treat it as burned and replace it.

Revoke, don’t delete

Removing it from .env does nothing. Revoke or roll it at the provider so the leaked copy stops authenticating.

Rotate on a schedule

And whenever someone with access leaves. Quarterly is a reasonable default for high-value keys.

Scope to least privilege

A read-only or resource-restricted key that leaks is a small fire. A root key that leaks is the whole building.

Already committed a key to git?

The most common real leak. The fix is not git rm. Order matters.

bash
# You just realised .env is in a public commit. Order matters.

# 1. ROTATE the key in the provider dashboard FIRST. Assume bots already have it.
# 2. Stop tracking the file:
git rm --cached .env
echo ".env" >> .gitignore
git commit -m "Stop tracking .env"

# 3. The value is still in git HISTORY. Purge it (secondary to the rotation):
#    git filter-repo --path .env --invert-paths     # or use the BFG
# 4. Turn on GitHub secret scanning + push protection so the next one is caught.

Rotating in step 1 is the part that actually makes the leaked value harmless. Purging the git history matters, but it’s secondary. The old value works until the provider stops honouring it.

Keep AI agents away from your secrets

Agents read files and print output. Left alone, one will cat .env into the chat.

  • Ignore files

    Add .env and .env.* to .gitignore, and to any agent ignore such as .cursorignore or .aiexclude. Many agents won’t open the file at all.

  • State the rule in your project instructions

    A line in CLAUDE.md, .cursorrules, or your system prompt telling the agent never to read or print secrets. Most agents follow it.

  • Reference by name, never by value

    Say STRIPE_SECRET_KEY, not the value. Have the agent write process.env.STRIPE_SECRET_KEY in code, never the literal string.

  • Watch the debugging moments

    The classic slip is dumping the whole environment to see what’s set. If a real key surfaces that way, it’s exposed. Rotate it.

Rule to paste into your agent

text
Treat secrets as radioactive. Never read, cat, print, echo, log, or paste the
contents of .env, .env.*, or any file containing credentials. Never output the
literal value of an API key, token, password, connection string, or secret,
even while debugging. Refer to every secret by its variable NAME only (for
example STRIPE_SECRET_KEY), and in code always read it via process.env or
import.meta.env, never inline the value. If you think you need a secret's value
to proceed, stop and ask me instead of revealing it.

Checklist

Run through this before you ship anything that reads a key.

  • Every key is classified: publishable (safe in the browser) or secret (server-only), and you know which is which.
  • No secret key carries a VITE_ or NEXT_PUBLIC_ prefix. Only publishable and config values do.
  • .env and .env.* are in .gitignore, and a keyless .env.example is committed in their place.
  • Production secrets live in the host’s environment-variables panel (or the VPS process environment), never in the repo.
  • You’ve grepped a production build (dist/ or .next/) and confirmed no secret shipped to the client.
  • Vercel “Sensitive” is on for write-once credentials and off for values you’ll need to read or edit.
  • High-value keys are scoped to least privilege, and you have a rotation habit: on exposure, on offboarding, and on a schedule.
  • .env is in your agent’s ignore file, and your project instructions tell the AI never to read or print secrets.
  • Any key that has ever touched a bundle, a commit, a log, or a chat has been rotated, not just deleted.

Prompt your AI assistant

Paste this to have your AI tool audit, not fix, your secrets and env vars.

text
Audit this repository for leaked or mis-scoped secrets and environment
variables. Do not fix anything yet, just report findings with file and line.

1. Find any secret credential (API key, token, password, connection string,
   private key, service-role key) hardcoded as a literal in source instead of
   read from process.env / import.meta.env.
2. Find any secret value assigned to a variable with a VITE_ or NEXT_PUBLIC_
   prefix, or otherwise exposed to the client bundle. These are public. List
   each one and say what it grants.
3. Confirm .env and .env.* are in .gitignore. Scan the git history for any
   committed .env or hardcoded secret.
4. Check whether a keyless .env.example exists and matches the variables the
   code actually reads.
5. For each real secret you find exposed, describe the concrete blast radius
   (what a stranger holding it could do) and whether it needs rotating.

Do NOT print the actual value of any secret you find. Refer to each by its
variable name only.

Want the full guide as context for your AI coding assistant?