The fastest way to lose money with an LLM app is to let a key leak: anyone who finds it can spend your quota until you revoke it. The defence has three layers. Keep keys out of the code path, block them at commit time, and have a rotate-first runbook for the day one gets out anyway.
Everything below was run on 2026-10-09 in a scratch repo. Every "key" in the demos is fake, shaped like a real one so the patterns trigger, and valid nowhere. The vendor claims come from the vendors' own docs pages, fetched that day.
Where do API keys leak in LLM apps?
Most leaks aren't clever attacks. They're ordinary files going somewhere they shouldn't.
| Where it leaks | Why it happens | What to do |
|---|---|---|
Committed .env or source file | git add . on day one | .gitignore before the first commit, commit hook, push protection |
| Browser bundle | A NEXT_PUBLIC_ variable holds a model key | Per the Next.js docs, these are inlined at build time into JavaScript sent to the browser. Call the model from server code only |
| Docker image | Key passed as build argument or ENV | Per the Docker docs, build args and environment variables persist in the final image. Use secret mounts |
| Notebook output | A cell printed the client config | Clear outputs before commit, scan .ipynb files too |
| CI logs | set -x or a debug print | Mask secrets in CI settings, never echo them |
| Chat and tickets | Pasting a stack trace with the key in it | See what not to paste into ChatGPT |
| Coding agents | The agent reads .env and quotes it | See coding agent safety and permissions |
| Shared team key | One key for everyone, never rotated | One key per person or service, or virtual keys behind a proxy such as in LiteLLM budgets and virtual keys |
The pattern: a key that only ever exists in a server environment variable can't be committed, bundled or baked into an image by accident. Everything after that is a safety net.
What do GitHub secret scanning and push protection actually do?
This is what the GitHub docs pages said when fetched on 2026-10-09. Plans and names change, so check the pages before you rely on a detail.
- Secret scanning looks through your whole Git history on all branches for known credential types. It also scans issue, pull request and discussion text, wikis and secret gists.
- Availability: public repositories get it free. Private and internal repositories owned by an organisation need GitHub Secret Protection on Team or Enterprise Cloud.
- Push protection blocks a push that contains a detected secret: from the command line, the web UI, file uploads and the REST API. For repositories it needs Secret Protection and is off until an admin enables it. A separate push protection for users is on by default on GitHub.com for public repositories.
- Bypass is possible. Anyone with write access can bypass with a reason ("used in tests", "false positive" or "I'll fix it later"). The first two create closed alerts and the third an open one. Admins can require delegated bypass instead.
- Coverage: the supported-patterns page listed 530 patterns when read, including an Anthropic API key entry, and notes that push protection only supports the most recent token versions it can identify with confidence.
- If it alerts: the docs say to rotate the credential immediately.
Sources: About secret scanning, About push protection, Supported patterns.
None of that helps a private repo on a free personal account, or a repo that isn't on GitHub. That's where a local layer earns its place.
A scanner you can run locally
Here is a small standard-library scanner. It checks seven provider-style patterns plus a generic "name looks secret and value looks random" rule. The patterns are my own demo patterns, not vendor-verified formats. Real tools such as gitleaks or trufflehog (not run here) maintain far larger rule sets, so treat this as something to learn from and adapt.
#!/usr/bin/env python3
"""Tiny secret scanner for LLM apps. Standard library only. Demo patterns, not vendor-verified.
Modes:
python3 secret_scan.py --file PATH scan a file
python3 secret_scan.py --staged scan added lines in `git diff --cached`
python3 secret_scan.py --history REPO_DIR scan added lines in every commit of a repo
Exit code 1 when anything is found, so a git hook can block the commit.
"""
import math
import re
import subprocess
import sys
RULES = [
("aws-access-key-id", re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b")),
("anthropic-style-key", re.compile(r"\bsk-ant-[A-Za-z0-9_-]{16,}")),
("openai-style-key", re.compile(r"\bsk-(?!ant-)(?:proj-)?[A-Za-z0-9_-]{20,}")),
("github-token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36}\b")),
("slack-token", re.compile(r"\b(?:xox[baprs]-[A-Za-z0-9-]{10,}|xapp-\d-[A-Za-z0-9-]{10,})")),
("private-key-block", re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----")),
("url-with-password", re.compile(r"\b[a-z][a-z0-9+.-]*://[^\s:/@]+:[^\s:/@]{4,}@[^\s/]+")),
]
GENERIC = re.compile(r"""(?ix)(api[_-]?key|secret|token|passwd|password)\w*["']?\s*[:=]\s*["']?([A-Za-z0-9/_+=.-]{16,})""")
PLACEHOLDER = re.compile(r"(?i)your|changeme|example|placeholder|xxxx|<|\$\{|test|dummy")
def entropy(s):
return -sum(s.count(c) / len(s) * math.log2(s.count(c) / len(s)) for c in set(s))
def scan_line(line):
if "secret-scan: allow" in line:
return []
hits = [name for name, rx in RULES if rx.search(line)]
if not hits:
m = GENERIC.search(line)
if m and entropy(m.group(2)) >= 3.5 and not PLACEHOLDER.search(m.group(2)):
hits.append("generic-high-entropy")
return hits
def report(where, lineno, hits, line):
shown = line.strip()
shown = shown if len(shown) <= 48 else shown[:20] + "..." + shown[-8:] # never print the whole value
print(f"{where}:{lineno}: {','.join(hits)}: {shown}")
def main(argv):
found = 0
if argv[0] == "--file":
for i, line in enumerate(open(argv[1], encoding="utf-8", errors="replace"), 1):
hits = scan_line(line)
if hits:
found += 1
report(argv[1], i, hits, line)
elif argv[0] in ("--staged", "--history"):
cmd = ["git", "diff", "--cached", "-U0", "--no-color"] if argv[0] == "--staged" else \
["git", "-C", argv[1], "log", "-p", "-U0", "--no-color", "--all", "--format=commit %h"]
out = subprocess.run(cmd, capture_output=True, text=True).stdout
path, ref, n = "?", "", 0
for line in out.splitlines():
if line.startswith("commit "):
ref = line.split()[1]
elif line.startswith("+++ b/"):
path = line[6:]
elif line.startswith("@@"):
n = int(re.search(r"\+(\d+)", line).group(1)) - 1
elif line.startswith("+") and not line.startswith("+++"):
n += 1
hits = scan_line(line[1:])
if hits:
found += 1
report(f"{ref}:{path}" if ref else path, n, hits, line[1:])
print(f"findings: {found}")
return 1 if found else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
Note the report function prints only the start and end of a line. A scanner that echoes whole secrets into terminal logs and CI output just creates a second leak.
How well does it work?
I wrote 22 fixture lines by hand: 12 that a real leak could look like and 10 harmless ones that look suspicious. Then I ran the scanner on each line. Output from the run:
label hit rules line
1 1 aws-access-key-id AWS_ACCESS_KEY_ID = "AKIAIOSFODNN7EXAMPLE"... ok
1 1 anthropic-style-key ANTHROPIC_API_KEY=sk-ant-api03-EXAMPLEEXAMPL... ok
1 1 openai-style-key client = OpenAI(api_key="sk-proj-EXAMPLEEXAM... ok
1 1 github-token GITHUB_TOKEN=ghp_0123456789abcdefghijklmnopq... ok
1 1 slack-token SLACK_BOT_TOKEN=xoxb-0000000000-000000000000... ok
1 1 slack-token SLACK_APP_TOKEN=xapp-1-A0000000000-000000000... ok
1 1 private-key-block -----BEGIN RSA PRIVATE KEY-----... ok
1 1 url-with-password DATABASE_URL=postgres://app:s3cr3tpw@db.inte... ok
1 1 generic-high-entropy client_secret = "demoK3J9xQ2mZpL8vR4tYw7nB1"... ok
1 1 generic-high-entropy curl https://api.example.com/v1/items?api_ke... ok
1 0 - KEY_B64 = "c2stYW50LWFwaTAzLUVYQU1QTEVFWEFNU... MISSED
1 0 - headers = {"x-api-key": "sk-ant-" + "api03-E... MISSED
0 1 generic-high-entropy secret_name = "projects/demo/secrets/anthrop... FALSE POSITIVE
0 1 url-with-password DATABASE_URL=postgresql://user:password@loca... FALSE POSITIVE
tp=10 fn=2 fp=2 tn=8
recall=0.83 precision=0.83 (n=22, hand-labelled, tiny)
(The eight correctly ignored harmless lines are omitted here for space. They included os.environ["ANTHROPIC_API_KEY"], a placeholder your-key-here, a 40-character commit hash and max_tokens = 1024.)
Read the failures, because they teach more than the passes:
- Missed: base64-wrapped key. A line-by-line regex can't see inside an encoding. GitHub's pattern page lists base64 support for some patterns, which is one reason to run the platform scanner too.
- Missed: key assembled from two string pieces. No single line contains the key. Nothing line-based will catch this, and you shouldn't write code like it.
- False positive: a secret's name.
projects/demo/secrets/anthropic-keyis a path to a secret, not the secret. - False positive: a documentation URL.
user:password@localhostis an example. Add# secret-scan: allowon such lines, or extend the placeholder list.
Twenty-two lines is a smoke test, not a benchmark. The point is to know what your layer does and doesn't catch, and you can do that for any scanner with a dozen labelled lines.
How do I block a commit that contains a key?
Git runs .git/hooks/pre-commit before each commit and aborts if it exits non-zero. Create it, make it executable, and point it at the scanner (here at scripts/secret_scan.py in the repo):
#!/bin/sh
# Block the commit if the scanner finds anything in the staged diff.
exec python3 scripts/secret_scan.py --staged
I made it executable with chmod +x, staged a file with a fake key and tried to commit. The real output:
$ git commit -m "add client"
app.py:3: anthropic-style-key: client = anthropic.A...XAMPLE")
findings: 1
The commit did not happen. After changing the file to anthropic.Anthropic() (which reads ANTHROPIC_API_KEY from the environment), the same command reported findings: 0 and committed.
Two companion checks cost nothing. Put .env in .gitignore before the first commit and confirm it works:
$ git check-ignore -v .env
.gitignore:1:.env .env
And trying git add .env was refused: "The following paths are ignored by one of your .gitignore files".
Hooks live in .git/, which is not copied when someone clones. Share them through your repo (a scripts/ folder plus a setup line in the README) or a hook manager, and expect people to skip them.
How do I find a key that is already in git history?
I proved the skipping problem on purpose. I committed a fake OpenAI-style key with git commit --no-verify (the hook never ran), then fixed the file in the next commit so the latest version was clean. A scan of the working tree finds nothing. The history scan does:
$ python3 secret_scan.py --history /tmp/C5f/repo
4072fed:notes.py:1: openai-style-key: OPENAI_API_KEY = "sk...PLE0000"
findings: 1
The key is gone from the newest commit and alive in 4072fed. Run a history scan once on any repo you inherit or open-source, and in CI on every push.
What do I do the minute a key leaks?
Order matters, and the most common mistake is starting with the git cleanup.
- Revoke or rotate the key in the provider console. Per GitHub's docs on removing sensitive data, once a secret is revoked or rotated it can no longer be used for access, and the extra history rewrite may not be warranted.
- Check usage for the whole window between the commit and the revocation. Look for requests from places or at times you don't recognise. If your provider has spend limits or alerts, check what they showed.
- Replace the key everywhere it ran: servers, CI secrets, teammates' machines, scheduled jobs. A rotated key that one forgotten cron job still uses becomes an outage.
- Decide on history rewriting. GitHub's page lists side effects: changed commit hashes, broken diffs for closed pull requests, and a high risk of someone re-pushing the data from an old clone. Commits may also remain reachable in forks and cached views. For a revoked key, many teams reasonably skip it.
- Add the control that would have caught it: the hook, the ignore rule, push protection, a server-only variable.
- Write it down in a short incident note. The incident postmortem template works for this.
A copyable checklist for an LLM app
- Model keys exist only as server-side environment variables, never
NEXT_PUBLIC_or equivalent. -
.gitignorecovers.envand.env.*(allowing.env.example) from the first commit. - A pre-commit hook runs a secret scan on the staged diff.
- Push protection or CI scanning is on, so a skipped hook isn't the last line.
- One key per environment (dev, staging, production) and per service, so rotating one doesn't stop everything.
- Docker builds use secret mounts, not build args or
ENV. - Logs, traces and error reports are scrubbed of headers and keys (the PII redaction post shows a pattern for prompts).
- Public endpoints that spend your quota have limits, as in rate limiting a free-tier AI app, so a stolen app is cheaper than a stolen key.
- Someone has practised rotating each key once, and knows where the provider console is.
- Servers holding keys are hardened, see the VPS hardening checklist.
What I did not verify
I did not run gitleaks, trufflehog or GitHub's scanner, so I make no comparison with them. The scanner's regexes are demo patterns, not checked against each vendor's current key format. The GitHub, Next.js and Docker statements come from their docs pages as fetched on 2026-10-09 and could change. Your plan decides which GitHub features you actually have.



