Skip to main content
Auth, Identity & Security
🔐 Auth & SecurityLesson 8 of 13

Secrets Management

Env vars, vaults, rotation — keep keys out of your code and your Git history.

Secrets Management

Stage 3 · Auth, Identity & Security · B.U.I.L.D. letter: L

You shipped your first full-stack app in a weekend — API wired up, database humming, auth working. Then someone on Reddit posts a screenshot of your AWS bill: $47,000. Congratulations, your API key was public in your GitHub repo for six hours. This lesson is how you make sure that never happens to you.


⚠️ The vibe trap

When you're moving fast, it feels natural to paste an API key directly into your code just to make the thing work. You think: I'll clean it up before I push. You forget. You push. Even if you catch it ten minutes later and delete the line in a new commit, the key is still sitting in your git history forever — any git log or git clone exposes it. GitHub, GitLab, and every CI system that ever cloned your repo now has a copy. Deleting the commit does not help unless you rewrite history AND rotate the key.


🗝️ What counts as a secret

A secret is any value that grants access to a resource or proves identity — and whose exposure would let an attacker do something you don't want them to do.

Secrets include:

  • Database connection strings (DATABASE_URL=postgres://user:pass@host/db)
  • Third-party API keys (Stripe, Twilio, SendGrid, OpenAI, AWS)
  • JWT signing secrets and cookie signing keys
  • OAuth client secrets (not client IDs — those are public)
  • Private keys for TLS, SSH, or code signing
  • Webhook secrets used to verify inbound requests
  • Service-account tokens and session tokens

Not secrets (fine to expose):

  • OAuth client IDs
  • Public API endpoints
  • Feature flags that carry no privilege
# SECRETS — never commit, never log, never expose to client
DATABASE_URL=postgres://app:s3cr3t@db.prod.internal/myapp
STRIPE_SECRET_KEY=sk_live_...
JWT_SECRET=a-long-random-string-at-least-32-chars
SENDGRID_API_KEY=SG.xxxxxxxxxxxxxxxx

# NOT secrets — safe to expose
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
NEXT_PUBLIC_APP_URL=https://myapp.com

Mental model: If this value were posted on Twitter, could a stranger rack up charges, read your users' data, or impersonate your server? If yes, it's a secret.

Why it matters: Secrets grant capabilities. Unlike a password you can tell someone to change, a leaked API key can be used silently for days before you notice charges or a breach.

Common mistake: Treating an OAuth client ID as a secret. It isn't — it's meant to be public. Confusing it with the client secret (which IS secret) is one of the most common OAuth leaks.


📁 .env files and .gitignore

The standard pattern for local development is a .env file that lives only on your machine and is never committed.

# .env  — local dev only, NEVER committed
DATABASE_URL=postgres://app:password@localhost:5432/myapp_dev
JWT_SECRET=dev-only-not-real-please-change-in-prod
OPENAI_API_KEY=sk-...
STRIPE_SECRET_KEY=sk_test_...

Your .gitignore must include it — and every related variant:

# .gitignore — secrets and local config
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.env*.local

# Also ignore any key files you might generate locally
*.pem
*.key
serviceAccountKey.json
credentials.json

Add a .env.example file with real key names but fake values and commit that. It documents what your app needs without exposing any actual secrets.

# .env.example — SAFE to commit, shows required keys with dummy values
DATABASE_URL=postgres://user:password@localhost:5432/myapp
JWT_SECRET=replace-me-with-a-random-32-char-string
OPENAI_API_KEY=sk-your-key-here
STRIPE_SECRET_KEY=sk_test_your-key-here

Mental model: .env is a lock box on your machine. .env.example is the label on the outside saying what goes in it. Commit the label, not the box.

Why it matters: Every team member and every future deployment needs to know which secrets to configure. .env.example is that documentation, and it never leaks real values.

Common mistake: Committing .env.production because you figure it has prod keys so you want it saved somewhere. That somewhere should be a secret store, not git.


🌐 Anything sent to the browser is public — full stop

In Next.js, any environment variable prefixed NEXT_PUBLIC_ is bundled into the JavaScript that ships to the browser. Every visitor to your site can read it in DevTools. This is intentional for things like your Stripe publishable key or your app's public URL — but it is a hard rule that you cannot put a secret there.

// ✅ Safe — this key is meant to be public, it only identifies your Stripe account
const stripe = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY);

// ❌ FATAL — this key has full read/write access to your Stripe account
// Anyone who opens DevTools sees this
const stripe = new Stripe(process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY); // DON'T DO THIS

The same rule applies in any framework with a build step — Vite's VITE_ prefix, Create React App's REACT_APP_ prefix, and so on. If the framework exposes it at build time, it ends up in the bundle.

Mental model: The server runs in a trusted environment. The browser runs on a stranger's computer. Never send keys to strangers.

Why it matters: Browser-bundled secrets are trivially extracted. Unlike a git leak that requires someone to find your repo, a browser-bundled key is served to every single visitor.

Common mistake: Calling a third-party API directly from the browser to avoid writing a backend route. Write the route. The route lives on your server. The server uses the secret. The browser calls your route.


🏭 Secrets in production: platform secret stores

In production you cannot use a .env file — there is no machine to put it on, and even if there were, it would be outside version control and unaudited. Every major hosting platform has a built-in secret store:

PlatformWhere to set secretsHow to access in code
VercelProject → Settings → Environment Variablesprocess.env.MY_SECRET (same as local)
RailwayService → Variablesprocess.env.MY_SECRET
RenderService → Environmentprocess.env.MY_SECRET
AWSSecrets Manager or Parameter StoreSDK call or injected at deploy time
Fly.iofly secrets set KEY=valueprocess.env.MY_SECRET

For high-security workloads (fintech, healthcare, anything regulated) use a dedicated vault like HashiCorp Vault or AWS Secrets Manager. These provide audit logs, fine-grained access control, automatic rotation, and versioning.

# Vercel CLI — set a secret for production only
vercel env add STRIPE_SECRET_KEY production

# Fly.io — set a secret (never appears in git or logs)
fly secrets set STRIPE_SECRET_KEY=sk_live_...

# AWS CLI — store a secret in Parameter Store
aws ssm put-parameter \
  --name "/myapp/prod/STRIPE_SECRET_KEY" \
  --value "sk_live_..." \
  --type SecureString

Mental model: The platform secret store is the .env file for the cloud. It's encrypted at rest, access-controlled, audited, and never in your code repo.

Why it matters: If your production server gets compromised and an attacker reads the filesystem, a .env file hands them everything. A platform vault limits the blast radius and gives you logs showing what was accessed.

Common mistake: Hard-coding different secrets per environment (if (NODE_ENV === 'production') { key = '...' }). This bakes secrets into your code. Use environment variables and let the platform inject the right value per environment.


🔄 Rotation: treat secrets like passwords, not tattoos

Secrets should expire. Rotate them:

  • On any personnel change (developer leaves the team)
  • After any suspected or confirmed leak
  • On a scheduled cadence for high-value keys (quarterly is a common baseline)
  • Whenever you rotate a key for one environment, rotate all environments

Most services (Stripe, Twilio, etc.) let you create a second key, deploy the new one, then revoke the old one — zero downtime rotation.

# Zero-downtime rotation pattern
# 1. Create new key in the provider's dashboard
# 2. Add the new key to your platform secret store
# 3. Deploy (app now uses new key)
# 4. Verify traffic looks healthy
# 5. Revoke the old key in the provider's dashboard

🚨 When a secret leaks: the remediation playbook

Assume compromised the moment you suspect a leak. Do not wait to confirm abuse. The cost of rotating a key is minutes. The cost of waiting is potentially catastrophic.

# Step-by-step leaked-key remediation

# 1. ROTATE IMMEDIATELY — go to the provider dashboard right now
#    Stripe: stripe.com/settings/api-keys → Roll key
#    OpenAI: platform.openai.com/api-keys → Delete + create new
#    AWS: IAM → Create new access key → deactivate old

# 2. UPDATE your platform secret store with the new key
vercel env rm OPENAI_API_KEY production
vercel env add OPENAI_API_KEY production   # pastes new value

# 3. PURGE git history — BFG Repo Cleaner is the standard tool
#    (do this AFTER rotating so the old key is already dead)
java -jar bfg.jar --replace-text secrets.txt your-repo.git
git reflog expire --expire=now --all
git gc --prune=now --aggressive
git push --force

# 4. AUDIT — check provider logs for unauthorized usage
#    How long was the key exposed? What calls were made?
#    File an incident report even if no abuse is found.

# 5. SCAN the full history to find other leaks before they become incidents
git log --all --full-history -- '**/.env*'
git grep -i "api_key\|secret\|password\|token" $(git rev-list --all)

Mental model: A leaked secret is like a lost house key. You don't wait to see if anyone uses it — you change the lock immediately.

Why it matters: Attackers run automated scanners on GitHub. A key can be harvested within seconds of a push. Rotating within the hour limits damage dramatically.

Common mistake: Deleting the file with the secret and making a new commit, then assuming the secret is gone. It is not gone. Every commit in that branch's history still contains it. You must rewrite history or the secret lives forever.


🔍 Scanning git history for leaked keys

Before you ship — and periodically after — scan your repo for anything that looks like a secret. Do this even if you're confident; developers are forgetful.

# gitleaks — the industry-standard secret scanner
# Install: https://github.com/gitleaks/gitleaks
gitleaks detect --source . --verbose

# Also scan the full git history (not just the working tree)
gitleaks detect --source . --log-opts="--all" --verbose

# truffleHog — alternative, good for scanning remotes
trufflehog git file://. --only-verified

# Quick manual grep for common patterns (no install required)
git log --all -p | grep -iE \
  "(api[_-]?key|secret[_-]?key|access[_-]?token|password|private[_-]?key)" \
  | head -40

Add gitleaks to your CI pipeline as a pre-merge check so future leaks are caught before they land in the main branch.


🌍 Separating secrets per environment

Your dev, staging, and production environments should have completely separate secrets. This is not just good hygiene — it limits blast radius. If a dev machine is compromised, the attacker gets dev credentials that can only reach your local or staging database.

Environment    Database                    Stripe Key
─────────────  ──────────────────────────  ──────────────────────────
Development    localhost:5432/myapp_dev    sk_test_dev_...  (test mode)
Staging        staging-db.internal/myapp  sk_test_staging_...
Production     prod-db.internal/myapp     sk_live_...      (live mode!)

Never use a production secret in development. Never use a live payment key to run tests. Stripe test mode exists exactly for this — you can run full payment flows with sk_test_ keys and no real money moves.


🛠️ Your mission

Open the app you've been building in this track. Do a complete secrets audit:

  1. Search every file for hardcoded strings that look like API keys, database URLs, or tokens. (grep -rn "sk_\|pk_\|postgres://\|mongodb+srv://" src/)
  2. Move every secret you find into a .env file.
  3. Verify .env is in your .gitignore — add it if it isn't.
  4. Create a .env.example with the same keys and placeholder values.
  5. Check git log -p | grep -i "api_key\|secret\|password" — if anything shows up in history, rotate that key now.
  6. Review any environment variables exposed with a framework prefix (NEXT_PUBLIC_, VITE_, REACT_APP_) — confirm none of them are secrets.

✅ You're done when…

  • You have run the Security Audit Checklist (covered in the audit lesson) against your secrets configuration and can account for every item in the Sensitive Data Exposure category.
  • Every secret in your app lives in .env locally and in your platform's secret store in production — zero secrets are hard-coded in source files.
  • .env (and all .env*.local variants) appear in .gitignore and are not tracked by git.
  • A .env.example file is committed to the repo with correct key names and fake values.
  • git log -p | grep -i secret returns nothing alarming — or you have rotated anything it found.
  • No secret-bearing value is prefixed with NEXT_PUBLIC_, VITE_, or any other framework build-time exposure prefix.
  • You can articulate the difference between a Stripe publishable key (public, safe in browser) and a Stripe secret key (private, server only).

➡️ Next: Securing File Uploads.

Build It Right, Or Don't Build It At All. 🏛️

Pre-launch security · partner tool

Built it? Now scan it. The HYVE Audit finds security holes before launch — $55, and your code never leaves your machine.

Run the audit ↗

Always-on rigor toolkit

🏛️ Build It Right, Or Don't Build It At All.