How the keys to your project leak

Leonardo Gurgitano4 minLeer en español

The closed facade of a tall building, with a single visible opening.
The closed facade of a tall building, with a single visible opening.

Nobody commits an API key on purpose. And yet it happens all the time, in projects run by people who know what they are doing.

It happens because the routes it leaks through do not look dangerous at the time. There are four of them, they are worth knowing, and the first is the one that surprises people most.

1. Deleting it in the next commit does not delete it

This is the one to understand before any other.

You accidentally committed a file with a key in it. You notice five minutes later, delete it, commit and push.

The key is still there. Git keeps the whole history: the commit where you added it is still present, and anyone who clones the repository can see it with one command:

git log -p --all -S "sk_live_"

That searches the entire history for any commit where that text appeared, even if it was deleted afterwards.

And on a public repository there are programs dedicated to running exactly that search, constantly, over everything that gets published. The time between pushing a key and somebody using it is measured in minutes.

What to do if it happens to you: cleaning the history is possible, but it is the second step. The first, always, is to revoke the key and generate a new one. Once it has been published, you have to assume somebody has it, and all the effort of rewriting history does not change that.

2. The file that was not in .gitignore

The classic case. Your .env is properly ignored, but:

.env            ← ignored
.env.local      ← ignored
.env.production ← NOT ignored!

Or you created config.local.json to try something out and it never made it onto the list.

The rule that prevents the problem is to invert the logic: ignore everything starting with .env and make an explicit exception for the example.

# Ignore every variant
.env*

# Except the example file, which we do want to track
!.env.example

And before each commit, look at what you are about to push:

git status

It sounds obvious. It is the check that prevents the most leaks, and the one most often skipped when you run git add . without looking.

3. Logs and error messages

This one is not in the repository, so no tool that looks at your code will catch it.

// Looks harmless
console.log('Calling the API', { url, headers });

If headers contains the authorisation token, you have just written it to your logs. And logs tend to be kept for months, to travel to an external service, and to be visible to more people than the database is.

The same goes for errors. Many libraries include the complete request in the message when something fails, headers and all.

The habit that prevents it: never log a whole object that might contain credentials. Pick the fields:

console.log('Calling the API', { url, method: 'POST' });

4. The code you hand to an AI tool

This one is new and still not on most people’s minds.

When you paste a configuration file into a chat to ask for help, or when a coding assistant reads your project, any keys in those files leave your machine.

This is not an argument against using those tools. It is one more reason for keys never to be written in the code: if they live in environment variables, the file you share says process.env.API_KEY and not the value.

How to find out whether you already have one out there

Search the whole history for the most common prefixes:

git log -p --all -S "sk_live_"     # Stripe
git log -p --all -S "AKIA"         # AWS
git log -p --all -S "ghp_"         # GitHub
git log -p --all -S "BEGIN RSA"    # private keys

And for a complete review there are dedicated tools that scan the entire history in one go, such as gitleaks or trufflehog.

The safeguard you already have

Some good news: GitHub has push protection enabled by default on all public repositories, including free ones. If you try to push something it recognises as a credential, it rejects the push and tells you exactly what it found.

It detects credentials for dozens of services: AWS keys, GitHub tokens, Stripe, Databricks, Shopify and many more, and the list grows every month.

With two limits worth being clear about:

It only recognises identifiable formats. A Stripe token starts with sk_live_ and gets detected. Your database password is just some string, and there is no way to tell it apart from ordinary text.

It does not review the history you already pushed. It protects you from here on. What is already there is still there.

The minimum I would leave configured

Three things, and none of them takes more than five minutes:

One. A .gitignore with .env* and the exception for the example.

Two. A tracked .env.example with the names of the variables and none of the values. It works as documentation and makes it obvious which file is the real one that stays out of the repository.

# .env.example
DATABASE_URL=postgres://user:password@localhost:5432/database
STRIPE_SECRET_KEY=sk_test_...

Three. A review of the history now, once, with the commands above. If something turns up, you already know the first step: revoke that key before anything else.

Comments