# How to catch a leaked API key before it reaches the repository

> Layer a gitleaks hook, GitHub push protection, a TruffleHog scan in CI, and a Thinkst canary key, then plant a key to prove which layer catches it and when.

Source: https://voxgig.com/howto/catch-a-leaked-api-key-before-push

- Audience: platform-team
- Level: beginner
- Languages: typescript, javascript
- Verified: 2026-10-20
- Published: 2026-10-17

## Short answer

Run gitleaks as a pre-commit hook with a rule for your own key format, so a commit holding a key fails on the laptop. Turn on GitHub push protection for the organization to block the push that skipped the hook. Scan history with TruffleHog in CI, with unverified results included. Plant a Thinkst canary key where a real one would sit, and treat any caught key as a rotation, never as a cleanup.

---
## You will need

Node 22 or later, a git repository you can add hooks to, and `curl` and `tar` on the PATH for the
installer. Verified 2026-10-20 against Node 22.22.0, gitleaks 8.30.1, and TruffleHog 3.99.2. The
sample downloads both scanners at those versions into `node_modules/.bin`, checks each archive
against its published `sha256`, and runs them over a throwaway tree. Push protection and the
canary run on GitHub's side and on Thinkst's, so this page describes them from their
documentation. The hook framework is [pre-commit](https://pre-commit.com/#install).

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Canary credentials (Thinkst Canarytokens)](https://docs.canarytokens.org/guide/aws-keys-token.html) | A repository or laptop an attacker might read anyway, and you want to know the day it happens | A fake key every scanner in this table reports, so each needs an exception for it, and an alert that arrives after the exposure, behind CloudTrail's delay | Nobody is on call to act on an alert |
| [GitHub push protection](https://docs.github.com/en/code-security/concepts/secret-security/push-protection) | Code hosted on GitHub, where a block at push cannot be skipped the way a hook can | The Secret Protection add-on for private repositories, patterns limited to GitHub's partner list until an organization publishes its own, and a bypass anyone with write access can take | The code is on another forge |
| [gitleaks pre-commit hook](https://github.com/gitleaks/gitleaks) | Every laptop, catching the key before a commit exists and before anything leaves the machine | A hook each contributor installs for themselves, two documented ways to skip it, and an allowlist to maintain for every fixture it misreads | Contributors you cannot ask to install a tool |
| [TruffleHog in CI](https://github.com/trufflesecurity/trufflehog) | History the hook never saw, scanned after the push with each candidate key verified against its provider | Verification sends every candidate key to its provider, and the documented CI flags drop anything unverified, your own key formats included | A key format TruffleHog cannot verify and you will not write a detector for |

The four fire at different moments. The hook fires before a commit exists, but one flag skips
it. Push protection fires on the push that skipped the
hook, for the patterns GitHub knows. TruffleHog fires after the push, over commits the hook never
read, and stays silent about keys it cannot verify unless told otherwise. The canary fires
when the key is used, the one moment the other three cannot see.

## Pin the scanners and describe your own key

The scanners arrive through `npm ci`: a `postinstall` script downloads each release archive,
checks its `sha256` against the published value, and unpacks the binary into `node_modules/.bin`.

```js title="install-scanners.mjs"
const archive = join(BIN, `${name}.tar.gz`)
console.log(`${name} ${tool.version}: downloading ${platform} archive`)
execFileSync('curl', ['-fsSL', '--retry', '3', '-o', archive, tool.url(tool.version, platform)], { stdio: 'inherit' })

const digest = createHash('sha256').update(readFileSync(archive)).digest('hex')
if (digest !== tool.sha256[platform]) {
  rmSync(archive)
  console.error(`${name}: sha256 ${digest} does not match the pinned ${tool.sha256[platform]}`)
  process.exit(1)
}
```

gitleaks reads `.gitleaks.toml` from the root of whatever it scans. Extending the default
configuration keeps its rules, the AWS rule among them, and adds yours; the second block is the
canary's exemption.

```toml title=".gitleaks.toml"
[extend]
useDefault = true

[[rules]]
id = "example-live-key"
description = "Live API key issued by api.example.com"
regex = '''\bex_live_[0-9a-f]{32}\b'''
keywords = ["ex_live_"]

[[allowlists]]
description = "deploy/credentials holds a Thinkst canary. Its use is the alert; its presence is not."
targetRules = ["aws-access-token", "generic-api-key"]
paths = ['''^deploy/credentials$''']
```

The allowlist names two rules and one path, so a key anywhere else is still reported, and so is an
`ex_live_` key in that file.

A Thinkst canary is a real AWS key pair for an account Thinkst controls, issued at
[canarytokens.org](https://docs.canarytokens.org/guide/aws-keys-token.html) as a file shaped like
`~/.aws/credentials`. Any call made with it raises the alert.

Build the tree and run gitleaks the way the hook does.

```bash
node scan.mjs gitleaks
```

```text output
tree: config/production.env (a pasted AWS pair and an ex_live_ key),
      deploy/credentials (the canary), docs/aws-cli.md (the AWS documentation pair)

gitleaks dir . with the default rules and .gitleaks.toml: exit 1
  config/production.env:1  aws-access-token   REDACTED
  config/production.env:2  generic-api-key    AWS_SECRET_ACCESS_KEY=REDACTED
  config/production.env:3  example-live-key   REDACTED
  deploy/credentials       no findings
  docs/aws-cli.md          no findings
```

Three findings on one file, and exit code 1, which the hook turns into a refused commit. The
generic rule caught the AWS secret on line 2 by its variable name. The canary file and the documentation page produced nothing, the
first because of the allowlist and the second because the AWS documentation key is excluded by the
rule itself.[^1]

## Hook the scan to every commit

The hook is a [pre-commit](https://pre-commit.com/#pre-commit-configyaml---repos) entry pinned to
a release tag.

```yaml title=".pre-commit-config.yaml"
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.30.1
    hooks:
      - id: gitleaks
```

Run `pre-commit install` once per clone. The
[hook definition](https://github.com/gitleaks/gitleaks/blob/v8.30.1/.pre-commit-hooks.yaml) in the
gitleaks repository runs `gitleaks git --pre-commit --redact --staged --verbose`, so it scans the
staged diff, redacts what it finds, and picks up `.gitleaks.toml` from the repository root.
pre-commit builds the hook from source and downloads a Go toolchain when none is installed.

Both documented skips are a decision the committer makes. `git commit --no-verify`
[bypasses every pre-commit and commit-msg hook](https://git-scm.com/docs/git-commit#Documentation/git-commit.txt---no-verify),
and `SKIP=gitleaks` in the environment
[skips this hook alone](https://pre-commit.com/#temporarily-disabling-hooks).[^2] The next two
layers exist for the commits that skip it.

## Block the push that skipped the hook

Push protection runs on GitHub's side, so no flag on the laptop reaches it. An organization owner
turns it on for every repository at once; private repositories need the Secret Protection add-on.
A blocked push
comes back as a
[`remote:` message](https://docs.github.com/en/code-security/how-tos/secure-your-secrets/work-with-leak-prevention/push-protection-on-the-command-line)
naming the secret type, the commit, and the file and line. The URL in the message is where the
committer either removes the secret and pushes again, or records a bypass reason. Every bypass
creates an alert and an audit log entry, so the skip that was silent on the laptop is on the record
here.[^3]

The patterns are GitHub's partner list of provider formats, AWS keys among them, plus any
[custom pattern](https://docs.github.com/en/code-security/how-tos/secure-your-secrets/customize-leak-detection/define-custom-patterns)
an organization publishes after a dry run.

## Scan history in CI with verification on

TruffleHog's distinguishing step is verification: for a candidate AWS key it calls
`GetCallerIdentity` with the pair and reports the key as verified when the call succeeds. Its
README suggests `--results=verified,unknown` for CI, so a build fails only on a key that works.
The same filter drops every result TruffleHog cannot verify, and it cannot verify a format it has
no detector for.

```yaml title="trufflehog.yml"
detectors:
  - name: example-live-key
    keywords:
      - ex_live_
    regex:
      key: '\b(ex_live_[0-9a-f]{32})\b'
```

A [custom detector](https://github.com/trufflesecurity/trufflehog/blob/v3.99.2/pkg/custom_detectors/CUSTOM_DETECTORS.md)
with no `verify` block reports every match as unverified, so the workflow has to ask for unverified
results, or the job discards what the detector finds.

```yaml title="ci-secret-scan.yml"
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: trufflesecurity/trufflehog@v3.99.2
        with:
          version: 3.99.2
          extra_args: --results=verified,unverified,unknown --config trufflehog.yml
```

The action scans the commits a pull request adds, or those of a push to `main`, and exits 183
when it reports anything. The `@` reference pins the wrapper script and `version` pins the
container image it runs, which otherwise is whatever `latest` is on the day.

```bash
node scan.mjs trufflehog
```

```text output
trufflehog filesystem . --no-verification --fail
  --results=verified,unknown, the flags in the README: 0 findings, exit 0
  --results=verified,unverified,unknown: 2 findings, exit 183
    AWS                config/production.env:1   unverified
    AWS                deploy/credentials:2      unverified
  the same, with --config trufflehog.yml: 3 findings, exit 183
    AWS                config/production.env:1   unverified
    example-live-key   config/production.env:3   unverified
    AWS                deploy/credentials:2      unverified
```

With the README's flags, a tree holding a leaked pair and an unknown key produces nothing and
exits 0. With unverified results included, both AWS pairs are reported, the fabricated canary
among them; a real Thinkst key is
[recognized by its account number](https://github.com/trufflesecurity/trufflehog#canary-detection)
and labelled as one.

## Check it worked

```bash
node --test scan.test.mjs
```

```text output
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 6134.771237
```

The second test keeps the canary in place: a tree holding only the canary and the documentation
pair exits `0`, so planting it needs no bypass. The fifth reads every file in this directory and
fails if a complete key is in any of them. The sixth shows the price: a second AWS pair beside the
canary goes unreported.

## When it goes wrong

A scan catches a pushed key, the committer deletes the line, and the next scan is quiet.

```bash
node pitfall.mjs
```

```text output
scan with the pasted pair present:   3 findings, exit 1
scan after deleting the lines:       0 findings, exit 0
```

The scan reads the working tree, which is the one place the key has left. It is still in the
commit that introduced it, in every clone, and in any fork. GitHub's guidance on
[removing sensitive data](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/removing-sensitive-data-from-a-repository)
opens by saying to revoke or rotate the secret before any history is rewritten. A caught leak is a
rotation, and [rotating without breaking clients](/howto/rotate-api-keys-without-breaking-clients)
is its own task.

CI is green with a key in the diff. The job runs TruffleHog with `--results=verified,unknown`, and
the key is a revoked AWS pair or a format with no detector. The result is unverified, so the filter
drops it before the exit code is set. Include `unverified` in `--results` and add a detector for
your own format.

The hook never ran for one contributor. `pre-commit install` is per clone, and one flag skips an
installed hook, so the second and third layers are the answer.

## When not to do this

Do not treat the hook as a control. It runs on a machine the committer administers, with a
documented flag to skip it, and it protects the committer from an accident rather than the
repository from a decision. Push protection and the CI scan are the controls, and the canary is
the audit.

Do not allowlist by path for anything but the canary. A path exemption is a hole shaped like the
file an attacker would look in, and the second key that lands in that file is invisible.

Do not plant a canary nobody is watching. The alert arrives behind CloudTrail, up to half an hour
after the key is used.[^4] An alert in a channel nobody reads is a record of a breach rather than a
response to one.

Do not scan so that you can avoid rotating. Each layer reports an exposure, and rotation is what
ends it.

## Related how-tos

- [Keep test and live API keys from crossing environments](/howto/keep-test-and-live-keys-apart)

- [Rotate API keys without breaking your clients](/howto/rotate-api-keys-without-breaking-clients)

- [Call a keyed API from a browser without shipping the key](/howto/keep-an-api-key-out-of-browser-code)

## Last verified

Verified 2026-10-20 against Node 22.22.0, gitleaks 8.30.1, and TruffleHog 3.99.2, each downloaded
from its GitHub release and checked against the published sha256. Every output block is what the
command preceding it printed, over a throwaway tree that `scanners.mjs` builds in a temporary
directory. The pre-commit hook, GitHub push protection, and the Thinkst canary were not exercised:
the hook's command line is read from the gitleaks repository's hook definition, and the other two
from their documentation. TruffleHog ran with verification off, so no key was sent to any provider.

[^1]: AWS prints `AKIAIOSFODNN7EXAMPLE` and `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY` throughout
its documentation, seven times in the
[CLI configuration guide](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html)
alone. The [gitleaks rule file](https://raw.githubusercontent.com/gitleaks/gitleaks/v8.30.1/config/gitleaks.toml)
answers with an allowlist on its AWS rule, one regular expression reading `.+EXAMPLE$`, so a key
that ends in the word is not a key. TruffleHog reported nothing for the pair either.

[^2]: pre-commit's documentation presents `SKIP` as the smaller of two evils, a way to skip one
hook rather than every hook on the commit, and files it under the heading
[Temporarily disabling hooks](https://pre-commit.com/#temporarily-disabling-hooks). A tool that
documents the polite way to disobey it has thought about its users more carefully than most.

[^3]: GitHub's [table of bypass reasons](https://docs.github.com/en/code-security/concepts/secret-security/push-protection)
has three rows. Declaring the secret used in tests, or a false positive, creates an alert that is
already closed; a promise to fix it later creates one that stays open. The reason is recorded and
the paperwork is filed in the same motion, which is as close as a push can come to signing a form.

[^4]: Thinkst's [documentation](https://docs.canarytokens.org/guide/aws-keys-token.html) for the
AWS key token carries a note saying the alert first passes through Amazon's logging
infrastructure, which may introduce a delay of between two and thirty minutes. Thinkst's
[account of building the token](https://blog.thinkst.com/2017/09/canarytokens-new-member-aws-api-key.html)
names that infrastructure: CloudTrail logs the call, CloudWatch filters the log, and a Lambda
function trips the token.