What it is and why it matters
When you connect a service to your app, like a database, payments or an AI, you get a key. It's a long string, and the easiest thing is to paste it straight into the code. If you give it to Claude in the chat without telling it where to put it, sometimes it does the same.
The problem is where the code ends up. If you push it to GitHub, the key travels with it. In a public repository anyone can read it, and there are programs running just to collect keys as soon as they show up. Whoever finds it uses it in your name.
And deleting it from the code isn't enough. Git keeps the whole history: if the key was in a commit, even just one, even months ago, it's still there. Anyone who downloads the repository finds it again.
Not all keys are secret. The public key of Supabase or Firebase is meant to live in the app: I cover it in the PORTA guide (strikt.fit/porta). Here I'm talking about the others: the database secret key, the AI key, the payments key, the email key. Those live only on the server, as in the LIMITE guide (strikt.fit/limite).
1. The check
Before fixing anything, look at what's there. Open Claude Code in the project folder and paste:
Search the project and the entire git history for keys, tokens and passwords written in the code, including config files and old commits. For each one, tell me the file, the line (or commit) and the service. Never print the value.
“The entire history,” otherwise Claude only looks at the current files. “Never print the value,” because you only need to know where it is: the key shouldn't show up in the chat.
A second opinion: gitleaks
A dedicated program recognizes the shape of a huge number of key types. I use gitleaks, which is open source. On a Mac you install it with Homebrew:
brew install gitleaks
Then, inside the project folder:
gitleaks git -v --redact gitleaks dir -v --redact
- gitleaks git looks at the entire history, commit by commit.
- gitleaks dir looks at the current files, including the ones git doesn't track.
- --redact hides the values in the results, -v shows the details.
On GitHub
In public repositories, GitHub's secret scanning is free and automatic: if it finds a key from a service it knows, it puts an alert in the Security and quality tab. Push protection goes further: it blocks a push that contains a key, before it gets online. For your own pushes to public repositories it's on by default; on the repository you turn it on from Settings → Advanced Security. On private repositories it may require a paid GitHub feature.
2. The keys in .env
The right place for a key is a .env file that git never uploads. It works like this:
- The keys go in a .env file in the project folder: KEY_NAME=value.
- In the .gitignore file, add the line .env: git never uploads it.
- Only the name stays in the code: process.env.KEY_NAME (your framework or dotenv reads the file).
- Wherever you publish the app, the same variables go in the hosting settings.
The prompt to have Claude do it:
Move the keys out of the code: put them in .env, add .env to .gitignore, create a .env.example with just the names, and check that no secret key ends up in the browser code.
The .env.example, on the other hand, you do upload: it only has the names, like AI_API_KEY= with nothing after it. That way, later on, you and whoever works with you know which keys are needed without seeing any of them.
If .env was already in the repository
The .gitignore only applies to files git isn't tracking yet. If .env had already been uploaded, .gitignore isn't enough: run git rm --cached .env, which removes it from the repository and leaves it on your computer, then make a commit. Then change the keys that were in it, because they stay in the history: see part 4. If you like, ask Claude to do it and explain each step.
3. The variables that end up in the browser
This is where people trip most often. Putting a key in .env doesn't make it secret: what matters is where the code that uses it runs. What runs on the server stays yours. What reaches the browser, or the app on the phone, anyone can see, .env or not.
Some frameworks deliberately send variables with a certain prefix to the browser:
- Next.js: NEXT_PUBLIC_. The value gets written into the JavaScript that reaches the browser.
- Vite: VITE_. The Vite docs say explicitly not to put keys there.
- Expo: EXPO_PUBLIC_. The value stays in plain text in the compiled app.
A secret key with one of these prefixes is public, even if it's in .env. It happens like this: the AI call from the browser doesn't work, the prefix gets added, now it works, and the key is out. The right fix is to move the call to the server.
If you don't want Claude to read .env
Claude Code can read the files in the folder, .env included. If you'd rather it didn't, add a deny rule to .claude/settings.json, in the project folder:
{
"permissions": {
"deny": ["Read(./.env)"]
}
}Add it after you've moved the keys: it also blocks writing to that file. It covers the tools Claude uses to read files and commands like cat, not a script that opens the file by itself: for that you need the Claude Code sandbox.
4. If one has already slipped out
If you find a secret key in a commit, on GitHub or in the code that goes to the browser, consider it burned. You don't know who has already copied it. Today, in this order:
- On the service's website, revoke the key and create a new one. This is the step that matters: whoever copied it can still use it.
- Put the new one only in .env and in the hosting settings.
- Check the service for strange usage.
- Then, calmly, clean up the history.
If the app is live, you can create the new key a moment before revoking the old one, so it doesn't go down. But you revoke the old one today, not tomorrow. For the last step, ask for an explanation first:
A key ended up in an old commit. Explain how to remove it from the git history before touching anything.
Why this order. Removing a key from the history means rewriting the repository's history (GitHub recommends git-filter-repo) and uploading it with a force push, and old copies and forks still contain it. That's why GitHub puts revoking the key as the first step, and says that sometimes it's enough: a revoked key no longer opens anything.
Common mistakes
- Deleting the key from the code and stopping there. It stays in the history: revoke it and create a new one.
- Cleaning up the history before revoking the key. Revoke it right away: the cleanup comes later.
- Adding .env to .gitignore when it had already been uploaded. Remove it with git rm --cached .env and a commit, then change the keys that were in it.
- Putting a secret key in a NEXT_PUBLIC_, VITE_ or EXPO_PUBLIC_ variable. It ends up in the browser or the app: move the call to the server.
- Pasting new keys into the chat with Claude. Put them in .env yourself and only tell it the variable name.
Checklist
- Check done on files and history, with Claude and with gitleaks.
- Secret keys live only in .env and in the hosting settings.
- .env is in .gitignore and not in the repository.
- There's a .env.example with the names, no values.
- No secret key in NEXT_PUBLIC_, VITE_ or EXPO_PUBLIC_ variables.
- Every leaked key has been revoked and replaced.
- Push protection turned on for the GitHub repository.
Frequently asked questions
My repository is private. Do I still need to do this?
Yes. If one day you make it public or add someone, the history comes along whole, keys included. And variables that end up in the browser are public anyway.
Is the Supabase public key in a NEXT_PUBLIC_ variable okay?
Yes, it's meant to be in the browser. What protects the data are the rules on the database, as I explain in the PORTA guide. The secret key (secret or service_role), on the other hand, never.
I've already revoked the key. Do I still need to clean up the history?
Not always: a revoked key no longer opens anything. If there's something else sensitive in the history, like people's data, or you're not sure you revoked everything, clean it up, starting with the prompt from part 4.