TL;DR
- AI assistants trained on public repos reproduce hardcoded secrets because that's what they learned
- A pushed API key is effectively public even after deletion -- git history doesn't forget
- Add gitleaks as a pre-commit hook today -- five minutes, blocks the problem at the source
I've been doing code reviews for teams that have fully switched to AI-assisted workflows. Cursor, Copilot, Claude Code -- all of them. And there's one pattern I keep seeing that doesn't get enough attention: hardcoded credentials.
Not occasionally. Consistently. In nearly every AI-generated file that touches external services, the first draft drops a raw API key directly into the code. Sometimes it's in a comment. Sometimes it's a constant. Once I found it hardcoded into a URL string inside a fetch() call.
This isn't a bug in the AI. It's working as designed. The models were trained on public code, and public code is full of this. StackOverflow answers from 2015, tutorial repos, quick prototypes that never got cleaned up. The model learned "here's how you call this API" from examples that had the key right there in the source.
The Vulnerable Pattern
// CWE-798: Use of Hard-coded Credentials
const stripe = require('stripe');
const client = stripe('sk_live_4eC39HqLyjWDarjtT1zdp7dc'); // live key, hardcoded
async function chargeCustomer(amount, customerId) {
return await client.charges.create({ amount, currency: 'usd', customer: customerId });
}
The sk_live_ prefix marks it as a production Stripe key. I've seen this exact shape in multiple codebases this month. The AI fills it in because that's what completions in its training data looked like.
Why This Keeps Happening
Autocomplete makes it worse. When you type const stripe = stripe(, the model offers the most statistically likely completion. If the training data had keys in that position, it suggests a key-shaped value. The model has no concept of "this value is sensitive -- it should come from an environment variable."
The commit history problem compounds it. Even if you catch it and remove the key in the next commit, it's still in git history. A git log -p surfaces it. So does any scanner that checks commit history rather than just the current HEAD. Rotation is mandatory once a secret has been pushed.
The Fix
Two layers: prevention and detection.
Prevention -- environment variables with a startup assertion:
const stripe = require('stripe');
if (!process.env.STRIPE_SECRET_KEY) {
throw new Error('STRIPE_SECRET_KEY is not set');
}
const client = stripe(process.env.STRIPE_SECRET_KEY);
The assertion matters. Without it, an unconfigured environment silently ships with an empty string rather than failing loudly.
Detection -- gitleaks pre-commit hook:
# Install gitleaks
brew install gitleaks # macOS, or: go install github.com/gitleaks/gitleaks/v8@latest
# Add to .git/hooks/pre-commit
echo '#!/bin/sh
gitleaks protect --staged -v' > .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
Runs on staged changes only -- fast, under a second. If it finds a secret pattern, the commit is blocked before it lands.
If you've already pushed a secret:
- Rotate the key immediately -- assume it's compromised
- Use BFG Repo Cleaner to purge from history (faster than git filter-branch)
- Force-push with --force-with-lease, coordinate with your team
- Check your API provider's access logs for unauthorized usage
I've been running SafeWeave for this. It hooks into Cursor and Claude Code as an MCP server and flags these patterns before I move on. That said, even a basic pre-commit hook with semgrep and gitleaks will catch most of what's in this post. The important thing is catching it early, whatever tool you use.
Top comments (0)