Hardcoded API keys in a Next.js app: why deleting the line isn't enough
You pasted a live key into a file to "just get it working," shipped the feature, and a week later you spot it sitting there in plain text. So you delete the line, commit "remove hardcoded key," and move on. The problem: that key is still exposed. Deleting the line removes it from the current file — not from the commit where you first added it. Git stores every state your code has ever been in, and a string that was committed once is recoverable by anyone who can clone the repo.
This is the single most common way credentials leak, and it's worth understanding exactly why "I deleted it" doesn't fix it.
What a hardcoded secret actually is
A hardcoded secret is any credential written as a literal value in source code instead of being read from configuration at runtime. The canonical shape:
const STRIPE_SECRET_KEY = "sk_live_51Nk2pLZvKq9rT4xBwHc8mYdQ";
It doesn't have to be a Stripe key. The same mistake covers OpenAI keys (sk-…), AWS access keys (AKIA…), GitHub tokens (ghp_…), Supabase service-role keys, database passwords, JWT signing secrets — anything that grants access and was meant to stay private. The defining property is that the value is baked into a file that gets committed, rather than living outside the repo in an environment variable.
Where this happens in a Next.js app
Next.js makes this easy to get wrong because it blurs the line between server and client code. A lib/ module looks server-ish, but if a Client Component imports it, the literal travels into the browser bundle. The usual culprits:
- A shared SDK module like
lib/stripe.tsorlib/openai.tsthat hardcodes the key "so every route can import it." - Route handlers (
app/api/*/route.ts) where the key is inlined directly instead of read fromprocess.env. - Reaching for
NEXT_PUBLIC_to silence anundefined. A server-onlyprocess.env.STRIPE_SECRET_KEYisundefinedin Client Components, so it's tempting to either hardcode the value or slap aNEXT_PUBLIC_prefix on it — both ship the secret to every visitor.
Here's the version that compiles, works in production, and quietly hands your payment infrastructure to anyone who reads the repo:
import Stripe from "stripe";
// Initialised once and reused across the app.
const STRIPE_SECRET_KEY = "sk_live_51Nk2pLZvKq9rT4xBwHc8mYdQ";
export const stripe = new Stripe(STRIPE_SECRET_KEY);
- Criticallib/stripe.ts:4
Hardcoded secret in source
Move the secret to an environment variable, purge it from git history (e.g. git filter-repo / BFG), and rotate it at the provider.
That's live engine output on exactly this file, not a mockup. GuardLayer matches the value against known provider key formats — the sk_live_ prefix is unmistakably a Stripe secret key — and flags it as critical (CWE-798).
Why deleting the line does nothing
When you commit a file, git stores a snapshot. The secret you added lives in that snapshot — stored as a content-addressed blob — independent of whatever the file looks like now. Deleting the line creates a new snapshot; it doesn't rewrite the old one. The old commit, and the blob inside it, stays in the object database and travels with every clone, fork, and fetch.
Anyone with the repo can recover it in one command:
# Find every version of the file across all of history
git log --all --oneline -- lib/stripe.ts
# Print the file exactly as it was at any past commit
git show <commit>:lib/stripe.ts
If the repo is public — or ever was, even briefly — assume the key is already captured. Automated scanners crawl public GitHub continuously, and an exposed cloud key is typically found and abused within minutes of being pushed, not days. A sk_live_ Stripe key gives an attacker your charge history, customer records, and the ability to create charges and issue refunds. There is no "I'll rotate it later."
The fix, in the right order
Order matters. Rotate first if the repo was ever exposed, then clean history — not the other way around.
1. Rotate the key immediately. In the provider dashboard (Stripe, here), roll the key. The moment you do, the leaked value is dead — even though it's still sitting in your history, it no longer unlocks anything. This is the single most important step and the only one that's irreversible from the attacker's side.
2. Move the value to an environment variable. Replace the literal with a runtime read, and keep the real value in an untracked .env file:
import Stripe from "stripe";
// Read at runtime; the real value lives in .env (gitignored).
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
Make sure .env and .env.local are in .gitignore before you create them, and load the value from your host's environment in production (Vercel project settings, a secrets manager, etc.). Keep this module server-side only — never import it from a "use client" file — so the key can't be inlined into the bundle.
3. Purge it from history. Now rewrite the past so the old blob is gone. The maintained tool for this is git filter-repo:
# Put the replacement rule in a file (==> separates match from replacement)
echo 'sk_live_51Nk2pLZvKq9rT4xBwHc8mYdQ==>REMOVED' > replacements.txt
# Rewrite every commit, scrubbing the literal wherever it appeared
git filter-repo --replace-text replacements.txt
# Re-add the remote (filter-repo drops it) and force-push the rewrite
git push --force --all
(BFG Repo-Cleaner does the same job and is friendlier on very large repos.) Rewriting history changes commit hashes, so coordinate with collaborators — everyone re-clones afterward. And on a hosted forge, cached views and any existing forks may still hold the old blob, which is exactly why step 1 is non-negotiable.
A 30-second self-check
Run these against your repo right now:
# Provider-shaped secrets anywhere in the working tree
grep -rnE '(sk_live_|sk-[A-Za-z0-9]{20}|AKIA[0-9A-Z]{16}|ghp_)' . \
--exclude-dir={node_modules,.next,.git}
# A key/token/password assigned to a long string literal
grep -rniE '(api[_-]?key|secret|token|password)\s*[:=]\s*["'\''][^"'\'']{12,}' . \
--exclude-dir={node_modules,.next,.git}
# The part everyone forgets — search the whole history for the value
git log -p --all -S 'sk_live_' -- '*.ts' '*.js'
If any of these return a hit, you have a key to rotate and a history to clean. The last one matters most: a clean working tree tells you nothing about what's buried in old commits.
FAQ
I deleted the line and committed. Isn't that enough?
No. The secret is still in the earlier commit and recoverable with git show. You must rotate the key and rewrite history to remove the blob.
The repo is private — am I safe? Safer, but not safe. Private repos get cloned by CI, forked internally, exposed through misconfigured access, and accidentally made public. Treat any committed secret as compromised and rotate it.
Is it fine to hardcode test or sandbox keys?
A Stripe sk_test_ or other sandbox credential is lower-risk, but it's still a bad habit, and test keys can incur abuse or rate-limit pain. Use env vars everywhere so the pattern is consistent and you never confuse the two.
What about NEXT_PUBLIC_ variables?
Same outcome, different mechanism. A NEXT_PUBLIC_ value is inlined into the browser bundle at build time, so any secret behind that prefix ships to every visitor. Never put a server secret behind NEXT_PUBLIC_ — call the provider from a route handler or Server Action instead.
Do I really have to rewrite history, or is rotating enough? Rotating is the part that stops the bleeding. Rewriting history is hygiene — it removes the dead value so it can't mislead future readers or leak again if you later add scanning or open the repo. Do both; rotate first.
The reliable fix is to never let a secret reach a commit in the first place. GuardLayer scans every push for hardcoded provider keys (and 19 other Next.js + Supabase issues) and blocks the merge before the secret is in your history — which is the only point at which "delete the line" actually works.
Catch this before it ships — free
GuardLayer scans every push for this and 19 other Next.js + Supabase issues, with the exact fix inline.