Secrets in code and in git
In short: A secret (a key or password) committed to git stays in the history even after it is deleted: rotate it first, then clean the history, and have the code read it from environment variables.
Problem
The key you deleted
The shop takes payments through a provider. The program sends the provider an API key: a long string that proves "this is the shop". Whoever has the key can start payments and refunds in the shop’s name.
A colleague is in a hurry and writes the key straight into the code:
PAYMENT_API_KEY = "pay_live_7Qm2Xc9LkT4vB8nR1sZp6wHd"Somebody spots it in code review. The colleague deletes it with a new commit, "Remove the key from the code". The file is clean now.
Three months later the repo becomes public because of the shop’s open SDK. The next night refunds to strangers’ cards start going out from the shop’s account. Somebody ran one command:
$ git log -p -S pay_live_commit 9c41e07 Remove the key from the code- "pay_live_7Qm2Xc9LkT4vB8nR1sZp6wHd";commit 3a9f2d1 Add payments+ "pay_live_7Qm2Xc9LkT4vB8nR1sZp6wHd";Deleting did not remove the key. It added one more place where it shows.
The idea
Git remembers everything
Every commit is a snapshot of the files. A new commit does not change the old one; it sits on top of it. That is why git log -p shows both adding the key and deleting it.
A secret that went into git and left your machine has leaked. Not "maybe": there are copies in your colleagues’ clones, in forks, in the CI cache, in mirrors. You cannot find them, let alone delete them.
So the order is always the same:
- Rotation. In the provider’s dashboard you revoke the old key and issue a new one. Now the old one is useless, wherever it has been copied.
- Cleaning the history.
git filter-reporewrites every commit without the key. Thengit push --force, and everyone clones again. - The new key never goes into the code. The program reads it from outside: from the environment (environment variables) or from a secret store.
The reverse order is a mistake: while you clean the history, the old key still works.
A secret is anything that grants access: an API key, a database password, a key for signing tokens, the private key of a certificate. In the lesson on hostile input you protected the program from other people’s data. Here you protect other people from your own.
See it
The key in the history
def payment_key(): key = os.environ.get("PAYMENT_API_KEY", "").strip() if not key: raise RuntimeError("PAYMENT_API_KEY is not set") return keyPAYMENT_API_KEY = "pay_live_7Qm2Xc9LkT4vB8nR1sZp6wHd"def payment_key(): key = os.environ.get("PAYMENT_API_KEY", "").strip() if not key: raise RuntimeError("PAYMENT_API_KEY is not set") return keyStep by step, in words
- The payment key is in the code. Commit 3a9f2d1 "Add payments" saves it in the history.
- The next commit deletes these lines. The file is clean, but 3a9f2d1 still holds the key.
- Somebody has a copy of the repo: a clone, a fork, a CI cache. git log -p takes the key out of the old commit.
- First, rotation: the provider revokes the old key. The copy out there is now useless.
- Then the history: git filter-repo rewrites the commits without the key. The hashes are new, everyone clones again.
- The new key lives only in the environment (or a secret store). The code reads it by its name, PAYMENT_API_KEY.
- If the name is missing, the program stops at once and says what is missing: fail closed. No default key.
- With the variable set the shop starts. No secret in git, and the leaked one no longer works.
The scene starts with the code from the problem and ends with the code that reads the key from the environment. The leaked key stays in the copy out there, but it is revoked. The history is rewritten: the commits have new hashes and the key is not in them.
# 1. Rotation: revoke pay_live_…6wHd at the provider and issue a new key.# 2. The history: replacements.txt contains the line# pay_live_7Qm2Xc9LkT4vB8nR1sZp6wHd==>***REMOVED***git filter-repo --replace-text replacements.txtgit push --force --allThe idea
The secret comes from outside, and without it the program stops
The program reads the key from the environment: a table of names and values that the operating system gives every process. On your machine you set it with export PAYMENT_API_KEY=… (in PowerShell $env:PAYMENT_API_KEY = "…"). On the server, the system that runs the application sets it, usually from a secret store.
What matters is what happens when the key is missing. A new server, a misspelled name, a forgotten setting: the program stops at once and says what is missing. This is called fail closed. The opposite, fail open, is to start with an empty or "default" key and blow up at the first customer, or worse, to run with the wrong key.
def payment_key(): key = os.environ.get("PAYMENT_API_KEY", "").strip() if not key: raise RuntimeError("PAYMENT_API_KEY is not set") return keyThe key is never printed in full, not even in the log. If you need to see which key is loaded, show only the beginning and the last four characters:
def masked(key): return key[:9] + "…" + key[-4:]try: key = payment_key() print(f"Payments are ready, key {masked(key)}")except RuntimeError as error: print(f"The shop does not start: {error}")The shop does not start: PAYMENT_API_KEY is not setWhen the site checks the code there is no such variable, so the output is the refusal. On your machine, with the key set, the program prints Payments are ready, key pay_live_…6wHd.
os.environ.get returns the second argument when the name is not there. os.environ["PAYMENT_API_KEY"] would raise KeyError, which is fail closed too, but with a less clear message. Here except is only for one clean sentence. In a real application you let the exception stop the startup: the process ends with code 1.See it
A secret scanner
Best of all, the secret never reaches a commit. So a scanner runs before every commit: a program that looks for strings in the changes that look like secrets. Here is a small one with three rules (regular expressions):
- the provider’s key has a known form:
pay_live_and 24 characters; - a URL with a password inside:
://user:password@; - assigning a string of at least 8 characters that does not start with
$topassword,secretorkey.
The last line of the commit is "${PAYMENT_API_KEY}": this is a placeholder that the configuration system fills in from the environment. It is not a secret, and the scanner must not stop it.
RULES = [ ("payment key", re.compile(r"pay_live_[A-Za-z0-9]{24}")), ("password in a URL", re.compile(r"://[^:/\s]+:[^@\s]+@")), ("password or key", re.compile(r'(password|secret|key)\s*=\s*"[^"$]{8,}"', re.IGNORECASE)),]
def scan(text): found = 0 for number, line in enumerate(text.splitlines(), start=1): for name, pattern in RULES: if pattern.search(line): print(f"line {number}: {name}") found += 1 break return founddiff = """\+ payment.url = "https://api.pay.example/v1"+ payment.key = "pay_live_7Qm2Xc9LkT4vB8nR1sZp6wHd"+ db.url = "postgres://shop:Kq7vR2pX@db.internal/shop"+ mail.password = "winter-sale-2026"+ payment.key = "${PAYMENT_API_KEY}""""found = scan(diff)print(f"Found: {found}. The commit stops." if found else "Clean.")line 2: payment keyline 3: password in a URLline 4: password or keyFound: 3. The commit stops.Real scanners have hundreds of rules and also measure how "random" a string is (entropy) to catch keys without a known form. The idea is the same.
Try it
Predict the output
The same scanner, a different configuration file. What will it print?
config = """\mail.user = "orders@shop.example"mail.password = "${MAIL_PASSWORD}"db.url = "postgres://shop:Kq7vR2pX@db/shop""""found = scan(config)print(f"Found: {found}. The commit stops." if found else "Clean.")Line 1 is an email address: it has no ://, and user is not in the third rule. Line 2 is a placeholder: the third rule wants the string not to start with $. That leaves line 3, where the password is in the database URL itself.
Try it
Fill in
Where does the key come from?
key = .get("PAYMENT_API_KEY", "").strip()
if not key:
raise RuntimeError("PAYMENT_API_KEY is not set")os.environ is the environment’s dictionary, and .get(name, "") gives an empty string when the name is not there.Try it
Find the bug
A colleague does not want the shop to "go down because of one setting" and adds a fallback. Which line is the bug?
Pick the line with the bug.
The fallback key is still a secret in the code, and the program starts quietly when the setting is missing: fail open. On a new server the shop will run with the old key, which may already have leaked, and nobody will notice. The right thing is to stop with a clear message.
More precisely, if you are curious
.gitignore and .env.example. If the local secrets are in a .env file, its name is in .gitignore from the very first commit. What goes into git is .env.example with the names and empty values: PAYMENT_API_KEY=. That way a new colleague knows what to fill in without seeing the values.
A scanner before every commit. Doing it by hand does not work: people forget. The scanner runs by itself from a git hook (pre-commit). Ready-made ones are gitleaks, trufflehog and detect-secrets, usually through the pre-commit framework. GitHub also has push protection: it refuses a push with a key of a known service. The scanner is a net under the rope, not a reason to write secrets in the code.
A secret store. Environment variables are better than the code, but they are not ideal. Child processes see them, they sometimes end up in a crash dump, and they are easy to print by mistake. So on the server the secrets come from a store: HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, Google Secret Manager. They record who read what and when, rotate the keys on a schedule and hand out short-lived keys.
Thepython-dotenv package loads .env into os.environ during development. In production the environment comes from the system that runs the application.The cleaning, in detail. git filter-repo deliberately removes the origin remote after rewriting, so that you do not accidentally send the new history to the wrong place: you add it back and run git push --force --all and --tags. Colleagues have to clone again: an old clone merged back brings the key back into the history. Services like GitHub also keep old pull request references, so there you also write to support. The older tool for the same job is BFG Repo-Cleaner.
Secrets in CI (deploy keys, access to the container registry, the pipeline’s secrets) are a topic of the DevOps course ZeroToOps. This lesson is the part the programmer writes.
Exercise
The key in the log
Solve it in your own editor.
Secrets also leak through logs: the request to the provider is logged in full, with the key inside. Write clean, which replaces every pay_live_ + 24 characters key in a log line with its masked form, and run it on these three lines:
POST /pay 200 key=pay_live_7Qm2Xc9LkT4vB8nR1sZp6wHd amount=49.90POST /refund 200 key=pay_live_7Qm2Xc9LkT4vB8nR1sZp6wHd amount=12.00GET /health 200POST /pay 200 key=pay_live_…6wHd amount=49.90POST /refund 200 key=pay_live_…6wHd amount=12.00GET /health 200More tasks
- One more rule. Add a rule for a private key to the scanner: a line that starts with
-----BEGINand ends withPRIVATE KEY-----. - Exceptions. Add a list of allowed values (for example the sample key in the documentation) that the scanner skips.
- A check at startup. Write a function that checks all the needed names at once (
PAYMENT_API_KEY,DB_PASSWORD,MAIL_PASSWORD) and reports every missing one, not just the first.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Regular expressions · Lists: append, remove and sort · Lambdas · Untrusted input
- Continues in: Dependencies: other people’s code that yours runs · Cryptography you don't write yourself · The prompt is code · Guardrails: checks before and after the model
- Tasks on this topic: Security for developers