HomeGuidesStop committing API keys with Gitleaks and push protection
Guide
Stop committing API keys with Gitleaks and push protection
Block API keys before they reach GitHub: Gitleaks pre-commit and pre-push hooks, a CI scan, .gitignore for .env, push protection limits, and AI agent traps.
Independent notes from VibeLocked. Not a pentest, and not affiliated with the tools named below.
Most leaked API keys do not get stolen from a server. They get committed. A key pasted into a config file, a .env that slipped past .gitignore, a token in .cursor/mcp.json, and one git add -A later it is in your history for good. AI coding agents make this faster, because they stage everything, hardcode keys to make a demo work, and reach for --no-verify when a hook gets in the way.
The fix is cheap and runs before anything leaves your machine: Gitleaks in a pre-commit hook, the same scan again at push time, and once more in GitHub Actions where nobody can skip it. This guide gives tested files for all three, the .gitignore lines that matter, what GitHub push protection does and does not cover on free and private repos, and what to do when a key already got out.
Checked and tested on October 21, 2026 against the Gitleaks, pre-commit, Git and GitHub docs linked below. We ran every hook, config and workflow step on this page with Gitleaks 8.30.1, pre-commit 4.7.0 and Git 2.47.3 in throwaway repos with a local remote, using fake keys generated at runtime: 66 checks, all passing. The workflow steps ran in fresh full clones on our machine, and both workflow files pass actionlint 1.7.12. VibeLocked is independent and not affiliated with any of these projects.
The short version
- Ignore secret files with
.env,.env.*and!.env.examplein.gitignore, and untrack any.envthat is already in git withgit rm --cached .env. - Block commits with keys using Gitleaks as a pre-commit hook, either through the pre-commit framework or a short shell script.
- Scan again on push with a pre-push hook, so a commit made with
git commit --no-verifystill cannot leave your machine. - Scan in CI with Gitleaks over the full history (
fetch-depth: 0) on every push and pull request. Hooks are local. CI is the layer that also covers Lovable, Bolt and cloud agents. - Add rules Gitleaks lacks. Its default rules missed a Supabase
sb_secret_key in our test. A five line.gitleaks.tomlfixes that. - Know what GitHub gives you. Push protection is free on public repos. On private repos it needs paid Secret Protection on a Team or Enterprise plan.
- If a key was pushed, rotate it first. History rewrites are cleanup, not the fix.
Why vibe-coded repos leak keys
The patterns we see, and that we reproduced below:
- The agent runs
git add -Aorgit commit -aand stages whatever is in the folder, including a.env.localyour.gitignoredoes not cover. - To get past an error, the agent pastes the key straight into
lib/stripe.tsorsupabaseAdmin.tsinstead of readingprocess.env. - MCP setup writes a personal access token into
.cursor/mcp.jsonor.mcp.json, which is a normal project file and gets committed. - A
.envwas committed on day one, before.gitignoreexisted. Adding it to.gitignorelater does nothing for a file git already tracks. - A hook blocks the commit, and the agent retries with
--no-verify, or adds agitleaks:allowcomment to silence it. - Lovable, Bolt, Replit and cloud agents push to GitHub from their own servers. Your local hooks never run there.
Each layer below closes one of these. None of them is enough alone.
Step 1: keep secret files out of git
Put this at the top of .gitignore:
# Secrets: never commit these
.env
.env.*
!.env.example
*.pem
*.key
.claude/settings.local.json
!.env.example keeps a placeholder file tracked so teammates and agents know which variables exist. Put fake values in it, never real ones. The last line covers the file where Claude Code saves your local permission approvals (Claude Code adds it to your global git excludes when it creates the file, this line covers a hand made one).
Check that it works. Both commands should print the matching rule:
git check-ignore -v .env
git check-ignore -v .env.local
In our test, git add -A with .env, .env.local and .env.example in the folder staged only .env.example and the code.
Two limits. First, .gitignore does not untrack files git already has. We committed a .env, then added .gitignore, and the next git add -A still picked up its changes. Untrack it without deleting your local copy:
git rm --cached .env
git commit -m "Stop tracking .env"
Anything that was in that file is still in the old commits, so treat those keys as leaked and rotate them (see the last section). Second, git add -f .env overrides .gitignore. That is what the hook in step 2 is for. More on where each value belongs in environment variables in Cursor, Bolt and Lovable.
Step 2: block the commit with a Gitleaks pre-commit hook
Install Gitleaks first: brew install gitleaks on macOS, or a binary from the releases page. Then pick one of the two setups below. Both scan only what you staged, so they take well under a second.
Option A: the pre-commit framework
If you already use pre-commit, add this as .pre-commit-config.yaml at the repo root:
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.1
hooks:
- id: gitleaks
Then run pre-commit install once per clone. The gitleaks hook id builds Gitleaks from source with Go on first use, so the first commit takes longer. A blocked commit looks like this (from our run, with the banner trimmed):
Detect hardcoded secrets.................................................Failed
- hook id: gitleaks
- exit code: 1
Finding: ...ort const stripe = "REDACTED"
Secret: REDACTED
RuleID: stripe-access-token
Entropy: 4.584184
File: pay.ts
Line: 1
Fingerprint: pay.ts:stripe-access-token:1
If Gitleaks is already installed and you would rather use that binary, the gitleaks-system hook id does it, but add pass_filenames: false:
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.1
hooks:
- id: gitleaks-system
pass_filenames: false
Without that line, Gitleaks 8.30.1's gitleaks-system hook gets the staged file names as arguments, and in our test it printed Passed for a commit with a key in it. This is a known open bug (gitleaks issue 1895). The plain gitleaks id sets the option already.
Option B: a plain Git hook, no extra tools
Save this as .git/hooks/pre-commit and run chmod +x .git/hooks/pre-commit:
#!/bin/sh
# .git/hooks/pre-commit: block commits that add secrets.
if ! command -v gitleaks >/dev/null 2>&1; then
echo "gitleaks is not installed, so this commit is blocked." >&2
echo "Install it (brew install gitleaks) and try again." >&2
exit 1
fi
exec gitleaks git --pre-commit --staged --redact --verbose --no-banner
--staged scans what you are about to commit, --redact keeps the key out of your terminal and logs, and the command -v check makes a missing Gitleaks block the commit instead of silently passing. We confirmed both: the hook blocked a fake key, and with Gitleaks removed from PATH it blocked every commit with the install message.
Git does not copy .git/hooks when someone clones the repo (we checked). To share hooks with your team and your own other machines, commit them in a folder and point Git at it in each clone:
git config core.hooksPath .githooks
Test the hook with a fake key
Never test with a real key. This generates a random string in Stripe's test key format, which Gitleaks' stripe-access-token rule matches:
echo "STRIPE_SECRET_KEY=sk_test_$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32)" > leak-test.txt
git add leak-test.txt
git commit -m "test: this should be blocked"
git reset -q leak-test.txt && rm leak-test.txt
The commit should fail with a finding. If it goes through, the hook is not installed or not executable. Delete the test file either way, and do not push it.
In our tests the hook also blocked a fake GitHub token in .cursor/mcp.json (rule github-pat), a fake Supabase service_role JWT in code (rule jwt), a .env added with git add -f, and a SUPABASE_SECRET_KEY=sb_secret_... line in a committed env file (rule generic-api-key).
Step 3: add rules Gitleaks does not have
Gitleaks 8.30.1 ships over 200 rules, but not one for Supabase's new sb_secret_ keys. When we hardcoded one in code, as createClient(url, "sb_secret_..."), the default rules let the commit through. The same key on a SUPABASE_SECRET_KEY= line was caught only by the generic keyword rule. Add a .gitleaks.toml at the repo root:
# .gitleaks.toml: keep every default rule, add the ones it lacks.
[extend]
useDefault = true
[[rules]]
id = "supabase-secret-key"
description = "Supabase secret API key"
regex = '''\b(sb_secret_[A-Za-z0-9_-]{20,})'''
keywords = ["sb_secret_"]
useDefault = true keeps every built-in rule. Gitleaks picks the file up automatically in hooks and CI. With it, the inline key was blocked under the rule id supabase-secret-key. Add a rule like this for any key format your app uses that the default config does not name. The rules match on the shape of the key, so you never put a real key in the config.
Step 4: scan again before every push
A pre-commit hook has an off switch built into Git. git commit --no-verify skips it, and so does SKIP=gitleaks git commit under the pre-commit framework. We tested both: the commit went through. A pre-push hook scans every commit you are about to send, so those commits still get caught before they leave your machine.
Save this as .git/hooks/pre-push (or in .githooks/) and make it executable:
#!/bin/sh
# .git/hooks/pre-push: scan every commit you are about to push,
# including commits made with git commit --no-verify.
command -v gitleaks >/dev/null 2>&1 || { echo "gitleaks is not installed, push blocked." >&2; exit 1; }
while read -r local_ref local_sha remote_ref remote_sha; do
case "$local_sha" in *[!0]*) ;; *) continue ;; esac # branch delete, nothing to scan
case "$remote_sha" in
*[!0]*) range="$remote_sha..$local_sha" ;; # existing branch: only new commits
*) range="$local_sha --not --remotes" ;; # new branch: commits no remote has
esac
gitleaks git --redact --verbose --no-banner --log-opts="$range" || {
echo "gitleaks found a secret in the commits you are pushing. Push blocked." >&2
exit 1
}
done
exit 0
For an existing branch it scans the new commits (remote..local). For a new branch it scans every commit that no remote has yet. In our test, a key committed with --no-verify was blocked on git push, on a push to a new branch too, and the remote never received it. After we removed the commit with git reset --soft HEAD~1 and recommitted without the key, the push went through.
git push --no-verify skips this hook as well. We confirmed that too, and that is why step 5 exists.
Step 5: scan the full history in GitHub Actions
Hooks live on one machine. CI runs on GitHub for every push and pull request, including pushes from Lovable, Bolt, Replit, cloud agents and teammates who never installed anything. Add .github/workflows/gitleaks.yml:
name: gitleaks
on:
push:
pull_request:
permissions:
contents: read
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Install gitleaks
env:
GITLEAKS_VERSION: 8.30.1
run: |
base="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}"
file="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
curl -sSfL -o "$file" "$base/$file"
curl -sSfL -o checksums.txt "$base/gitleaks_${GITLEAKS_VERSION}_checksums.txt"
grep " ${file}\$" checksums.txt | sha256sum -c -
tar -xzf "$file" gitleaks
- name: Scan all commits
run: ./gitleaks git --redact --verbose --no-banner --ignore-gitleaks-allow .
What each part does:
fetch-depth: 0fetches all history. The checkout default is one commit. We cloned our test repo with depth 1 after a key was added in one commit and deleted in the next, and Gitleaks found nothing. With full history it found the key and named the commit.- The checksum line fails the job if the downloaded release does not match Gitleaks' published checksums.
--ignore-gitleaks-allowmakes CI ignore inlinegitleaks:allowcomments. Locally, a fake key with// gitleaks:allowon the line passed the hook. With this flag the CI scan still failed on it. Real false positives go in a reviewed.gitleaksignorefile (one finding fingerprint per line) instead.--redactkeeps keys out of the Actions log. Our run printedREDACTEDfor the secret.
Then make the check required: in the repo's branch protection or ruleset for main, require the scan job to pass before merging.
Keep this workflow on push and pull_request with permissions: contents: read, as above. A scan that needs no secrets has no reason to run on pull_request_target, and a key can still reach the Actions log in a form the runner does not mask, such as a base64 value with a trailing newline. Both are covered in pull_request_target and secret masking, tested.
On a personal account you can use the official action instead, which also comments on pull requests:
name: gitleaks
on:
push:
pull_request:
workflow_dispatch:
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@v3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} # only needed for repos owned by an organization
The gitleaks-action README says repos owned by an organization need a free license key from gitleaks.io, and personal accounts do not. Use v3: the README says v2 runs on Node 20, which GitHub removed from hosted runners on September 16, 2026. We linted this file but did not run it, since the action only runs on GitHub. The CLI workflow above works the same for personal and organization repos without a license.
What GitHub push protection covers
GitHub has its own scanner that blocks pushes containing known key formats. Whether you get it depends on who owns the repo and your plan:
| Repo | Secret scanning and push protection |
|---|---|
| Public, any account | Free. Enable push protection in Settings, Advanced Security |
| Private, personal account (Free or Pro) | Not available |
| Private, organization on GitHub Free | Not available |
| Private, organization on Team or Enterprise | With GitHub Secret Protection, 19 US dollars per active committer per month |
There is also push protection for users, on by default for every GitHub account under Settings, Code security. It stops you pushing secrets to public repositories only, so it does nothing for your private app repo.
When it does run, it is good: it blocks pushes from the command line, the web editor, file uploads and the API, for formats from Stripe, OpenAI, Anthropic, GitHub, Supabase secret keys, Resend, Vercel and many more (full list). But anyone with write access can bypass a block by picking a reason, and it only knows those provider formats. Generic patterns like database connection strings are not blocked by default, and Clerk secret keys were not in the supported list when we checked. So even with push protection on, keep the Gitleaks hooks and CI scan. If a push gets blocked, GitHub's command line guide shows how to amend the commit.
Tell your AI agent the rules
Agents follow project instructions most of the time. Put this in AGENTS.md, CLAUDE.md or a Cursor rule:
Never hardcode API keys, tokens or passwords. Read them from environment variables.
Never stage .env files, and never use git add -f on an ignored file.
Never run git commit --no-verify, git push --no-verify, or SKIP=gitleaks.
Never add gitleaks:allow comments or edit .gitleaksignore or .gitleaks.toml.
If the gitleaks hook blocks a commit, stop and tell me which file and rule it reported.
In Claude Code you can also stop the agent reading env files with deny rules in .claude/settings.json, which the Claude Code settings docs show as "deny": ["Read(./.env)", "Read(./.env.*)"]. Cursor has .cursorignore, but its docs say the agent's terminal and MCP tools are not blocked by it, so treat it as a hint. Instructions and ignore files reduce mistakes. The hooks and CI are what actually stop them, so keep those even with a well behaved agent. If you wire MCP servers into your editor, the MCP server security guide covers keeping their tokens out of the repo.
Already pushed a key? Rotate first
If a key reached GitHub, even for a minute, even in a private repo, treat it as leaked:
- Revoke or rotate it at the provider before anything else. GitHub's secret scanning docs say to rotate immediately, and that removing the secret from history is time intensive and often unnecessary once the credential is revoked. Our first hour after an API key leaks has the rotate steps for Stripe, Supabase, OpenAI and others.
- Move the new key into an environment variable on your host, and change the code to read it.
- Check history with
gitleaks git --redact --verbose .in a full clone. Deleting the file in a new commit does not remove it: in our test the key was still found in the old commit. See how to check if your API key leaked on GitHub. - Rewrite history only if you need to, for example before making the repo public. Forks, clones, pull requests and GitHub's cached views can keep the old commit. The steps are in remove a leaked API key from GitHub history.
If the key never left your machine (a hook or push protection blocked it), there is nothing to rotate. Remove it from the commit with git commit --amend or git reset --soft HEAD~1 and commit again.
A note on Gitleaks itself
The Gitleaks README now says the project is feature complete and future releases will be security patches only, and its author is working on Betterleaks. Gitleaks 8.30.1 still works well for everything on this page, and the pinned versions in the files above keep your setup stable. If you switch tools later, the layers stay the same: hook, push hook, CI over full history.
Common AI-generated mistakes
Look for these in your repo and your agent's habits. Most are reproduced in our test file:
git add -Awith no.gitignoreentry for.env.localor.env.production. A plain.envline does not cover them. Use.envand.env.*.- Keys hardcoded to "make it work", like
createClient(url, "sb_secret_..."). Gitleaks' default rules missed that exact case for Supabase secret keys. - Tokens in
.cursor/mcp.jsonor.mcp.json, committed with the rest of the project. git commit --no-verifyorSKIP=gitleaksafter the hook blocks a commit.gitleaks:allowadded to the line to silence a finding. Only CI with--ignore-gitleaks-allowcatches it..gitignoreadded after.envwas committed, with nogit rm --cached.- A hook that skips itself when Gitleaks is missing (
command -v gitleaks || exit 0). Then a new laptop or a cloud agent has no protection and nobody notices. - The
gitleaks-systemhook withoutpass_filenames: false, which passed a commit with a key in our test. - A CI scan with the default shallow checkout, which missed a key that was added and deleted in later commits.
- Using
gitleaks-action@v2, which no longer runs on GitHub hosted runners. - Relying on push protection for a private repo on a personal account, where it does not run.
- Deleting the file and calling it fixed instead of rotating the key.
Test it yourself
- Run the fake key test from step 2. The commit should fail.
- Commit the same fake key with
git commit --no-verify, thengit push. The pre-push hook should block it. Remove the commit withgit reset --soft HEAD~1. - On a test branch, commit a fake key with
--no-verifyand push withgit push --no-verify. The gitleaks workflow should fail on that branch. Then delete the branch and do not merge it. - Run
gitleaks git --redact --verbose .on your main repo once. Any finding in old history is a key to rotate.
Use only generated fake keys for these tests. Never paste a real key into a test, a chat, or a website that offers to check it.
Checklist
-
.gitignorehas.env,.env.*and!.env.example, andgit check-ignore -v .envconfirms it -
git ls-files | grep -i envshows only.env.example(anything else getsgit rm --cachedand its keys rotated) - Gitleaks pre-commit hook installed (pre-commit framework or
.git/hooks/pre-commit), and the fake key test is blocked -
gitleaks-systemusers addedpass_filenames: false - Pre-push hook installed, and a
--no-verifycommit is blocked on push - Hooks shared through
.githooksandcore.hooksPathif more than one machine or person commits -
.gitleaks.tomlextends the defaults with rules for key formats your app uses, such assb_secret_ - GitHub Actions workflow scans full history with
fetch-depth: 0,--redactand--ignore-gitleaks-allow - The gitleaks job is a required check on
main - Push protection enabled if the repo is public, or if your organization has Secret Protection
-
AGENTS.md,CLAUDE.mdor a Cursor rule forbids--no-verify, hardcoded keys andgitleaks:allow - A full history scan of the repo came back clean, or every finding was rotated
Copy-paste prompt for your AI coding tool
Paste this into Cursor, Claude Code, Lovable, or Bolt. Share code only, never real keys or .env contents.
Set up secret scanning for this repo and audit it for committed secrets. Do not print, copy or move any secret value you find, only report the file, line and type. 1) Check .gitignore covers .env, .env.* (with !.env.example) and private key files, and list any tracked file that looks like an env file or holds credentials (git ls-files), including .cursor/mcp.json and .mcp.json. 2) Find hardcoded API keys, tokens, JWTs and connection strings in source and config files, and propose replacing each with process.env reads plus an entry in .env.example with a placeholder. 3) Add a .gitleaks.toml with [extend] useDefault = true plus rules for key formats this app uses that Gitleaks lacks, such as Supabase sb_secret_ keys. 4) Add a pre-commit hook that runs gitleaks git --pre-commit --staged --redact --verbose and blocks the commit if gitleaks is missing, and a pre-push hook that scans the commits being pushed, both in a .githooks folder. 5) Add a GitHub Actions workflow that checks out with fetch-depth: 0 and runs gitleaks git --redact --verbose --ignore-gitleaks-allow . on push and pull_request. 6) Add rules to AGENTS.md forbidding --no-verify, SKIP=gitleaks, gitleaks:allow comments and hardcoded keys. Show me the diff before changing anything. Never use --no-verify yourself, and if a hook blocks you, stop and tell me instead of working around it.
Read the diff before you accept it, then run the fake key test above. For a broader review, see the security review prompt for AI coding tools.
Related guides
- How to check if your API key leaked on GitHub
- The first hour after an API key leaks, with rotate steps per provider
- Remove a leaked API key from GitHub history
- Environment variables in Cursor, Bolt and Lovable
- Keep secrets out of frontend code, for keys that leak through the bundle instead of git
- MCP server security for Cursor and Claude Code
- Supabase anon key vs service_role key, and which one must never be committed
- Exposed .env, .git and source maps on a deployed app
- GitHub Actions secrets: pull_request_target, script injection and log masking
- Ship checklist for a vibe-coded app
Sources
- Gitleaks README (pre-commit setup, commands, config, gitleaks:allow, .gitleaksignore, feature complete notice)
- Gitleaks v8.30.1 release
- Gitleaks .pre-commit-hooks.yaml (gitleaks, gitleaks-docker and gitleaks-system hook ids)
- Gitleaks default rules (config/gitleaks.toml)
- Gitleaks issue 1895: gitleaks-system missing pass_filenames: false
- gitleaks-action README (v3, Node 24, license for organizations)
- Betterleaks
- pre-commit documentation
- Git: githooks (pre-commit, pre-push, core.hooksPath)
- Git: git commit (--no-verify)
- Git: git push (--no-verify)
- Git: gitignore (patterns, negation, already tracked files)
- Git: git check-ignore
- Git: git rm (--cached)
- actions/checkout (fetch-depth)
- actionlint
- GitHub: About push protection (repository vs user push protection, bypass reasons)
- GitHub: About secret scanning (availability by repo type, rotate first)
- GitHub: About GitHub Advanced Security (Secret Protection needs Team or Enterprise)
- GitHub: Advanced Security license billing (active committers)
- GitHub: Security plans and pricing
- GitHub: Enabling push protection for your repository
- GitHub: Push protection for users
- GitHub: Supported secret scanning patterns
- GitHub: Working with push protection from the command line
- GitHub: Removing sensitive data from a repository
- GitHub: About protected branches (required status checks)
- Claude Code: Settings (permissions deny rules for .env)
- Cursor: Ignore files (.cursorignore limits)
- Supabase: API keys (sb_secret_ keys)
- Stripe: API keys
Questions
How do I stop committing API keys to GitHub?
Use three layers. Keep .env files out of git with .gitignore. Run gitleaks as a pre-commit hook so a commit with a key fails on your machine, plus a pre-push hook that catches commits made with --no-verify. Then run gitleaks over the full history in GitHub Actions, because hooks are local, are not cloned, and never run for Lovable, Bolt or cloud agents that push for you. Turn on GitHub push protection where your plan includes it.
Is GitHub push protection free for private repositories?
No. Push protection for repositories is part of GitHub Secret Protection. It is free on public repositories. For private repositories it needs Secret Protection, which you can only buy on GitHub Team or Enterprise, at 19 US dollars per active committer per month. A private repo on a personal Free or Pro account has no push protection or secret scanning, so use gitleaks in a hook and in CI instead.
How do I set up gitleaks as a pre-commit hook?
Either add the gitleaks repo to .pre-commit-config.yaml with rev v8.30.1 and hook id gitleaks, then run pre-commit install, or put a short shell script in .git/hooks/pre-commit that runs gitleaks git --pre-commit --staged --redact --verbose and make it executable. If you use the gitleaks-system hook id, add pass_filenames: false, or it passes commits that contain keys.
Can git commit --no-verify skip gitleaks?
Yes. --no-verify skips the pre-commit hook, SKIP=gitleaks skips it under the pre-commit framework, and git push --no-verify skips a pre-push hook. AI agents use these when a hook blocks them. That is why the CI scan matters: it runs on GitHub for every push and pull request, and nobody can skip it from their laptop.
I already pushed an API key. Is deleting the file enough?
No. The key stays in every earlier commit, and in clones, forks and GitHub's cached views. Revoke or rotate the key at the provider first, then remove it from the code and read it from an environment variable. Rewriting git history is optional cleanup after the key is dead.