How-to › Authenticate and authorize calls

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.

Audience
Platform team
Level
beginner
Topic
Send API keys and bearer tokens
Languages
TypeScript and JavaScript
Verified

Read first: Call a keyed API from a browser without shipping the key

Somebody pastes a live AWS key pair into config/production.env to test a deployment and commits it with the rest of the change. Nothing on the laptop objects. The push succeeds, so the key is in the history of every clone before anyone reads the diff. Deleting the line in a later commit leaves it there.

What you get

You will end up with four layers that catch a planted key at four different moments, and a test that proves the two local ones do. This is for you if keys reach the repository in ordinary commits and the only check is review.

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.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
Canary credentials (Thinkst Canarytokens)A repository or laptop an attacker might read anyway, and you want to know the day it happensA 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 delayNobody is on call to act on an alert
GitHub push protectionCode hosted on GitHub, where a block at push cannot be skipped the way a hook canThe 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 takeThe code is on another forge
gitleaks pre-commit hookEvery laptop, catching the key before a commit exists and before anything leaves the machineA hook each contributor installs for themselves, two documented ways to skip it, and an allowlist to maintain for every fixture it misreadsContributors you cannot ask to install a tool
TruffleHog in CIHistory the hook never saw, scanned after the push with each candidate key verified against its providerVerification sends every candidate key to its provider, and the documented CI flags drop anything unverified, your own key formats includedA 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.

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.

[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 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.

node scan.mjs gitleaks
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 entry pinned to a release tag.

repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.30.1
    hooks:
      - id: gitleaks

Run pre-commit install once per clone. The hook definition 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, and SKIP=gitleaks in the environment skips this hook alone.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 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 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.

detectors:
  - name: example-live-key
    keywords:
      - ex_live_
    regex:
      key: '\b(ex_live_[0-9a-f]{32})\b'

A custom detector 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.

    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.

node scan.mjs trufflehog
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 and labelled as one.

Check it worked

node --test scan.test.mjs
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.

node pitfall.mjs
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 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 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.

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.

Footnotes

  1. AWS prints AKIAIOSFODNN7EXAMPLE and wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY throughout its documentation, seven times in the CLI configuration guide alone. The gitleaks rule file 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. ↩︎ Back to text

  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. A tool that documents the polite way to disobey it has thought about its users more carefully than most. ↩︎ Back to text

  3. GitHub’s table of bypass reasons 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. ↩︎ Back to text

  4. Thinkst’s documentation 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 names that infrastructure: CloudTrail logs the call, CloudWatch filters the log, and a Lambda function trips the token. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.