Ignore Secrets in Git
Version Control Remembers Everything
A version control system records every change, and it keeps every copy of every change for as long as the repository exists. That is what makes it valuable, and it is also why a secret committed once is a secret published forever. Deleting the file in the next commit does not remove the key from the history, and every clone of the repository carries it. Lesson 171 explained what to do when a key leaks. This lesson explains how to stop the leak from happening, using three layers: an ignore file that stops secret files from being added, a check on what is staged before a commit, and a scan of the contents, which catches a secret placed in a file with an innocent name.
The Ignore File
The version control system can be told to ignore certain paths, using a file conventionally named .gitignore. Listed paths are never staged by a normal add, which prevents the most common mistake: a real .env file committed alongside the code:
# secrets and local settings: never commit these
.env
.env.*
!.env.example
*.pem
id_rsa*
credentials*
service-account*.json
Two details matter. The !.env.example line is an exception: it re-includes the template, which is meant to be committed (Lesson 172). And the patterns must be broad enough to catch variants, such as .env.local or service-account-prod.json, because a narrow pattern covers only the name someone expected.
An ignore file is a convenience, not a guarantee. A file can be added with an explicit flag that overrides the ignore rules, and a file that was committed before it was ignored stays tracked. The ignore file prevents accidents, and the checks below catch the rest.
Checking What Is About to Be Committed
Before a commit is created, the staged files can be checked against the same rules, by name and by content. A check that runs automatically before each commit, usually through a pre-commit hook, stops a risky commit before it exists. The function below applies both checks to a set of staged files:
"""staged_check.py: refuse a commit that stages secret-like files or secret-like content."""
import re
from fnmatch import fnmatch
from os.path import basename
RISKY_NAMES = [".env", ".env.*", "*.pem", "id_rsa*", "credentials*", "service-account*.json"]
ALLOWED_NAMES = {".env.example"}
KEY_PATTERN = re.compile(r"sk-ant-[A-Za-z0-9_-]{10,}")
def risky_by_name(paths: list[str]) -> list[str]:
flagged = []
for path in paths:
name = basename(path)
if name in ALLOWED_NAMES:
continue
if any(fnmatch(name, pattern) for pattern in RISKY_NAMES):
flagged.append(path)
return flagged
def risky_by_content(staged: dict[str, str]) -> list[str]:
return [path for path, text in staged.items() if KEY_PATTERN.search(text)]
if __name__ == "__main__":
paths = ["src/app.py", ".env", ".env.example", "keys/prod.pem",
"service-account-prod.json", "README.md"]
print("risky names:", risky_by_name(paths))
staged = {"notes.txt": "rotate this before Friday: sk-ant-api03-ExampleValue42",
"app.py": "print('hello')"}
print("risky content:", risky_by_content(staged))
Output:
risky names: ['.env', 'keys/prod.pem', 'service-account-prod.json']
risky content: ['notes.txt']
How It Works
- The name check uses the file's base name, so a secret file inside a folder is caught as well as one at the top level.
- The template is allowed by name.
.env.examplematches the.env.*pattern, and the explicit allowance keeps it from being flagged, which mirrors the exception in the ignore file. - Patterns use shell-style matching, so
*.pemandservice-account*.jsoncatch whole families of names. - The content check catches a secret in an innocent file.
notes.txthas a harmless name, and its content contains a key, which the name check cannot see. - Both checks return lists, so a commit hook can print every problem at once and stop the commit, leaving the decision to remove the secret to the developer.
When a Secret Is Already Committed
If a secret has been committed, the damage is done once the commit has been pushed, and removing the file from the next commit is not enough. The steps are the same as in Lesson 171: revoke and replace the key first, because the old value may already be copied. Then remove it from the history. A history-rewriting tool can remove a file or a string from every commit, and after rewriting, the repository must be force-pushed and every collaborator must re-clone or reset. Forced rewriting breaks other people's work if it is done carelessly, so it should be coordinated, not done alone at night.
Rewriting history is cleanup, not protection. A key that was public for any time must be treated as compromised, whatever the repository looks like afterward.
Hooks and Teams
A check that depends on each developer remembering to run it is a check that will sometimes not run. Placing the check in a hook that the version control system runs automatically before each commit makes it the default. Hooks are local, so the team should also run the same check in the continuous integration system, which runs on every push and cannot be skipped by a local setting. The two layers together catch a secret that a developer bypassed locally.
Summary
Version control keeps every change, so a committed secret stays exposed in the history. An ignore file prevents secret files from being added, and an exception keeps the template in the repository. A pre-commit check finds risky names and risky content in the staged files, including secrets placed in innocuously named files. A committed secret must be revoked and replaced first, and only then removed from history, with the team coordinated. The next lesson moves from the secrets themselves to the input the application receives from its users.