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.

19 min read

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

  1. Ignore secret files with .env, .env.* and !.env.example in .gitignore, and untrack any .env that is already in git with git rm --cached .env.
  2. Block commits with keys using Gitleaks as a pre-commit hook, either through the pre-commit framework or a short shell script.
  3. Scan again on push with a pre-push hook, so a commit made with git commit --no-verify still cannot leave your machine.
  4. 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.
  5. Add rules Gitleaks lacks. Its default rules missed a Supabase sb_secret_ key in our test. A five line .gitleaks.toml fixes that.
  6. 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.
  7. 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 -A or git commit -a and stages whatever is in the folder, including a .env.local your .gitignore does not cover.
  • To get past an error, the agent pastes the key straight into lib/stripe.ts or supabaseAdmin.ts instead of reading process.env.
  • MCP setup writes a personal access token into .cursor/mcp.json or .mcp.json, which is a normal project file and gets committed.
  • A .env was committed on day one, before .gitignore existed. Adding it to .gitignore later does nothing for a file git already tracks.
  • A hook blocks the commit, and the agent retries with --no-verify, or adds a gitleaks:allow comment 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: 0 fetches 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-allow makes CI ignore inline gitleaks:allow comments. Locally, a fake key with // gitleaks:allow on the line passed the hook. With this flag the CI scan still failed on it. Real false positives go in a reviewed .gitleaksignore file (one finding fingerprint per line) instead.
  • --redact keeps keys out of the Actions log. Our run printed REDACTED for 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:

  1. 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.
  2. Move the new key into an environment variable on your host, and change the code to read it.
  3. 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.
  4. 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 -A with no .gitignore entry for .env.local or .env.production. A plain .env line does not cover them. Use .env and .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.json or .mcp.json, committed with the rest of the project.
  • git commit --no-verify or SKIP=gitleaks after the hook blocks a commit.
  • gitleaks:allow added to the line to silence a finding. Only CI with --ignore-gitleaks-allow catches it.
  • .gitignore added after .env was committed, with no git 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-system hook without pass_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

  1. Run the fake key test from step 2. The commit should fail.
  2. Commit the same fake key with git commit --no-verify, then git push. The pre-push hook should block it. Remove the commit with git reset --soft HEAD~1.
  3. On a test branch, commit a fake key with --no-verify and push with git push --no-verify. The gitleaks workflow should fail on that branch. Then delete the branch and do not merge it.
  4. 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

  • .gitignore has .env, .env.* and !.env.example, and git check-ignore -v .env confirms it
  • git ls-files | grep -i env shows only .env.example (anything else gets git rm --cached and its keys rotated)
  • Gitleaks pre-commit hook installed (pre-commit framework or .git/hooks/pre-commit), and the fake key test is blocked
  • gitleaks-system users added pass_filenames: false
  • Pre-push hook installed, and a --no-verify commit is blocked on push
  • Hooks shared through .githooks and core.hooksPath if more than one machine or person commits
  • .gitleaks.toml extends the defaults with rules for key formats your app uses, such as sb_secret_
  • GitHub Actions workflow scans full history with fetch-depth: 0, --redact and --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.md or a Cursor rule forbids --no-verify, hardcoded keys and gitleaks: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.

Sources

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.