Storage guide
How to store API keys securely: environment variables and beyond
Most API key leaks don't start with a sophisticated attack — they start with a developer following a quick-start tutorial that said "paste your key here." This guide covers the right pattern for every stage of a project: local development, preview deploys, production, and when a team grows large enough to need a real secrets manager.
- Why keys end up in code
- The .env file pattern
- Build-time vs. runtime injection
- Platform-native secrets
- When to use a secrets manager
- Mistakes to avoid
- FAQ
Why API keys end up in source code
The quick-start documentation for almost every API includes a code example with the key pasted directly into the snippet. The developer copies it, the code works, they commit it without thinking, and push. A few seconds later an automated bot that watches the GitHub public events API has harvested the key. This is not a theoretical scenario — it happens thousands of times per day across public repositories.
The root causes are always the same: the documentation normalized inline keys, the developer did not have a clear mental model for "where secrets go instead," and there was no automated check to catch the commit before it reached the remote. This guide fixes the middle problem — the mental model. The pre-commit hook and CI scan fix the third.
The .env file pattern
The foundation of API key management for local development is the .env file. A .env file is a plain text file that sits at the root of your project, contains your secrets as KEY=value pairs, and is never committed to version control. Your application reads the values from the file at startup, so the key is available to the code without ever appearing in a committed file.
Setting it up (Node.js / dotenv)
# Install the dotenv package
npm install dotenv
# .env — local secrets (NEVER commit this file)
OPENAI_API_KEY=sk-proj-abc123...
STRIPE_SECRET_KEY=sk_live_xyz789...
DATABASE_URL=postgresql://user:pass@localhost/myapp
// Load .env before any other imports that use process.env
import "dotenv/config";
// Now process.env.OPENAI_API_KEY is available
const response = await openai.chat.completions.create({
model: "gpt-4o",
messages: [{ role: "user", content: prompt }],
});
// openai SDK reads process.env.OPENAI_API_KEY automatically
Setting it up (Python / python-dotenv)
# Install
pip install python-dotenv
# In your code
from dotenv import load_dotenv
import os
load_dotenv() # reads .env from the project root
api_key = os.getenv("OPENAI_API_KEY")
The three files you need
| File | Contents | Committed? | Purpose |
|---|---|---|---|
.env | Real secrets — your actual keys and passwords | No. Never. | Local development only |
.env.example | Key names with placeholder values (OPENAI_API_KEY=your_key_here) | Yes | Documents which env vars the app needs; new devs copy this to .env and fill in their own values |
.gitignore | An entry for .env (and .env.local, .env.*.local) | Yes | Prevents git from ever tracking the real .env file |
# .gitignore — add these lines
.env
.env.local
.env.*.local
.env file and adding it to .gitignore — but the .gitignore entry does not protect files that were already committed. If you ran git add .env at any point, the file is tracked and git will commit it again on the next git add -A or git add ., even after you add the gitignore entry. Run git ls-files --cached .env to check. If it appears, remove it with git rm --cached .env first.
Build-time vs. runtime environment variables
This is the distinction that causes the most confusion with modern frontend frameworks, and it has real security consequences.
A runtime environment variable is read by your server process when it starts. The value is never baked into any file that leaves your server. It is appropriate for any secret — database passwords, secret API keys, webhook signing secrets.
A build-time environment variable is injected into your JavaScript bundle during the build step and shipped to every browser that loads your application. Next.js does this with any variable prefixed NEXT_PUBLIC_. Vite does it with VITE_ prefixed variables. The resulting value appears as a string literal in dist/assets/index-abc123.js — anyone who opens DevTools can read it.
| Type of key | Correct storage | Why |
|---|---|---|
Stripe publishable key (pk_live_) | Build-time / client-side OK | Designed to be public; Stripe uses it only to tokenize card data in the browser |
| Google Maps API key (browser-restricted) | Build-time / client-side OK | Restricted to your domain in the Google Cloud console; exposure is low-risk |
Stripe secret key (sk_live_) | Runtime / server-side only | Grants full API access; must never reach the browser |
| OpenAI / Anthropic API key | Runtime / server-side only | Billed per call; exposure means anyone can run queries on your account |
| Database password / connection string | Runtime / server-side only | Direct database access; never touches the client |
| Private OAuth client secret | Runtime / server-side only | Used for token exchange; client-side exposure breaks OAuth's security model |
| Webhook signing secret | Runtime / server-side only | Used to verify incoming webhooks; exposure allows signature forgery |
If your frontend needs to call an API that requires a secret key, the answer is always to put a server-side proxy in between: the browser calls /api/your-endpoint on your server, your server holds the key as a runtime env var, calls the third-party API, and returns the result. This is the pattern used by Next.js Route Handlers, Nuxt server routes, and any Express/Fastify backend. If you have no server, use a serverless function (Vercel Functions, Netlify Functions, Cloudflare Workers) as the proxy layer.
Platform-native secrets: no file required
When you deploy to a managed platform, you do not need a .env file on the server. Every major platform provides a UI for setting environment variables that are injected at runtime without touching the filesystem. This is strictly better than a file on disk: the values are encrypted at rest, scoped to your project, and not present in any artifact you deploy.
Vercel
Project → Settings → Environment Variables. Set per-environment scope: Production, Preview, Development. Variables marked "Sensitive" are encrypted and not shown after saving.
Netlify
Site → Site Configuration → Environment variables. Supports scoping by deploy context (production, deploy preview, branch deploys) and by role.
Railway
Project → Variables tab. Variables are injected into the container at runtime; shared variables can be synced across services in the same project.
Render
Service → Environment → Secret Files and Environment Variables. Secret Files lets you inject a whole .env file at runtime without it appearing in your repo.
GitHub Actions
Repository → Settings → Secrets and variables → Actions. Referenced in workflow YAML as ${{ secrets.MY_KEY }}. GitHub redacts the value from all log output automatically.
Fly.io
flyctl secrets set MY_KEY=value. Stored encrypted; injected as environment variables inside the VM. Never appears in fly.toml (which is committed).
When to use a secrets manager
Platform-native env vars are the right choice for most applications. A dedicated secrets manager becomes necessary when you hit specific requirements that env vars cannot satisfy.
| Requirement | Platform env vars | Secrets manager |
|---|---|---|
| One team, one service, one platform | Sufficient | Overkill |
| Multiple services needing the same secret | Must duplicate the value in each service's settings | One canonical store; all services fetch on startup |
| Automatic rotation on a schedule | Not supported | AWS Secrets Manager, HashiCorp Vault |
| Audit log of who accessed which secret when | Not available | AWS Secrets Manager, Vault |
| Developer-friendly UX (CLI, IDE plugin, .env sync) | Varies by platform | Doppler, 1Password Secrets Automation |
| Compliance (SOC 2, HIPAA, FedRAMP) | Insufficient alone | Required |
For most early-stage projects: use platform-native env vars. When you have multiple services consuming the same secret, or your security policy requires rotation and audit logs, evaluate AWS Secrets Manager, HashiCorp Vault, or Doppler (the most developer-friendly onramp for small teams).
Mistakes to avoid
-
✗
Hardcoding keys directly in source files
The most common mistake. Even in a private repository, any collaborator, GitHub Actions runner, or third-party integration that reads the repo has access to the key. Private repos become public, and repo access gets granted more broadly than intended.
-
✗
Committing a .env file
Often happens after adding .env to .gitignore but not removing the file from the tracked index first. Check with
git ls-files --cached .env. If it appears,git rm --cached .envremoves it from tracking without deleting the file. -
✗
Passing server-side secrets as NEXT_PUBLIC_ / VITE_ variables
These prefixes tell the build tool to inline the value into the client bundle. An OpenAI key passed as
NEXT_PUBLIC_OPENAI_API_KEYis shipped to every browser and readable in DevTools. Use a server-side Route Handler or API route instead. -
✗
Logging environment variables
console.log(process.env)orprint(os.environ)in a file that gets committed dumps every environment variable — including secrets — into your logs and potentially into the repository if you commit log output. Treat all env var values as opaque and never log the full environment object. -
✗
Bundling .env into a Docker image or deployment artifact
A
COPY . .in a Dockerfile copies everything in the build context, including any.envfile that is present. Add.envto your.dockerignorefile (not just.gitignore) and use Docker build arguments or runtime--env-fileinstead. -
✗
Sharing secrets over Slack, email, or GitHub issues
Chat messages and issue comments are stored indefinitely, often indexed by third-party apps, and frequently exported in data-retention archives. Rotate any key that was shared this way and share replacements through a password manager (Bitwarden, 1Password) instead.
-
✗
Using the same API key across all environments
A single key that serves development, staging, and production means a leak in any environment compromises all three. Most API providers make it easy to generate additional keys in their dashboard. The cost of managing separate keys is far lower than the cost of a production incident from a leaked dev key.
Check your current files for leaks
If you are reading this guide because you suspect a key may already be in your codebase or git history, paste a file or snippet into LeakCheck for a fast browser-based scan — no upload, no signup.
For a full audit of git history (including deleted files and past commits), see the companion detection guide:
Scan a git repository for secrets (gitleaks, GitHub Actions) →
I already committed a key — what do I do? →
Frequently asked questions
Is it safe to put API keys in a .env file?
Yes — with one condition: the .env file must never be committed to version control, and it must not be bundled into any artifact you deploy (Docker images, zip archives, tarballs). The security comes from keeping the file local-only. Add .env to both .gitignore and .dockerignore. Verify nothing slipped through with git ls-files --cached .env — if it returns output, the file is tracked and you need to run git rm --cached .env.
What is the difference between build-time and runtime environment variables?
A runtime variable is read by your server process when it starts; the value never leaves the server. A build-time variable (Next.js NEXT_PUBLIC_, Vite VITE_) is inlined into the JavaScript bundle and shipped to every browser. Build-time injection is appropriate for deliberately-public values like a publishable Stripe key. It is never appropriate for server-side secrets (secret keys, database passwords, OAuth client secrets). Those must stay runtime-only, read on the server side, and never passed through the build step.
My frontend app needs to call an API — how do I keep the key out of the JavaScript bundle?
Put the API call behind a server-side route that you control. The browser makes a request to your backend (e.g. /api/summarize), your backend holds the API key as a runtime env var, calls the third-party API, and returns the result to the browser. The key never leaves your server. In Next.js, use Route Handlers (app/api/route.ts). In Nuxt, use server routes (server/api/). If you have no server, use a serverless function (Vercel Functions, Netlify Functions, Cloudflare Workers) as the proxy.
Do I need to rotate a key that was only in a local .env file I never committed?
Not from the .env file itself — if it was never committed and never left the machine. You would still need to rotate if the key ended up in a Docker image pushed to a registry, in a deployment artifact (zip, tarball) uploaded somewhere, was shared directly over chat or email, or the machine was compromised. A key that stayed local-only in a file that was never committed or shared has no exposure vector from version control.
What is the difference between Vercel environment variables and AWS Secrets Manager?
Vercel's env var UI is the right choice for most Vercel-deployed applications — free, supports per-environment scoping, and integrates with the deploy pipeline. AWS Secrets Manager is appropriate when you need multi-service access to the same secret, automatic rotation on a schedule, or audit logs for every secret access (required for SOC 2 and HIPAA). Secrets Manager charges per secret per month. Most applications under roughly 10 services are better served by platform-native env vars until a compliance or rotation requirement appears.
Can I store API keys in a public repo if I encrypt them?
Not without a separate key management system to hold the decryption key — which puts you back at square one. If the decryption key is also in the repository, encryption adds no meaningful security. The correct pattern for secrets in CI is GitHub Actions Secrets: set the value in the GitHub UI, reference it as ${{ secrets.MY_KEY }} in the workflow YAML, and GitHub redacts the value from all log output automatically. The secret never appears in any repository file.
I found an API key in my codebase — what do I do first?
Rotate the key at the provider immediately — before rewriting git history, before removing it from the file. Assume the key is compromised the moment it appeared in a committed file. Rotation invalidates the old key so any attacker who already has it cannot use it. Then remove the key from the code, rewrite git history to expunge it from every commit, and set up environment variable storage going forward. Order matters: rotate first, clean up second. See the full incident response steps in the fix guide.
Also in the Copper Bay Labs ship-safety suite
Correct key storage is one layer. These free tools cover the rest of the pre- and post-deploy security stack:
Scan your live URL for exposed .env files, .git directories, source maps, and bundled secrets. Free, no signup.
Check your security headers — CSP, HSTS, X-Frame-Options, and more — so your site does not become an XSS vector.
Dependencies DepCheckScan your package.json for vulnerable, outdated, and abandoned npm packages before they reach production.