Software Architecture

Keeping Secrets Out of Source Control: Practical Secret Detection with Gitleaks

10 min readRuhi GargaGuideIntermediate

A secret that has been committed has been shared. Detection helps, but it works best as one layer in a delivery pipeline that is built to keep credentials out of source control in the first place.

Why a committed secret is hard to take back

Git keeps history, and history gets copied. A commit reaches every clone, may be fetched by CI runners, and can end up in forks and cached views on a hosting service. Deleting the file in the next commit removes it from the current tree. It does not remove it from the commits before.

The safe working assumption is that any secret that reached a shared remote is exposed and has to be replaced. That makes timing the main design question. The cheapest place to stop a secret is before it is committed, the next best is before it is merged, and the most expensive is after it has been pushed.

What Gitleaks is, and what it is not

Gitleaks is an open-source command-line scanner that looks for secrets such as passwords, API keys and tokens in Git repositories, in files, and in anything piped to it on standard input. It works from rules. Each rule has a regular expression, and many add keywords and a minimum entropy value to cut down noise. A built-in rule set covers common credential formats, and you can extend it with rules of your own.

It detects. It does not store, rotate or revoke secrets, and it does not stop anyone from reading one that is already exposed. I would treat it as a smoke alarm and not as a safe.

How secrets reach a repository

  • Config files. A .env file or settings file committed with real values, often because it was never added to .gitignore.
  • Fixtures and sample data. Test data copied from a real environment and never cleaned.
  • Scripts and notebooks. A token pasted in to get something working and left there.
  • Connection strings. Credentials embedded in a string that looks like ordinary configuration.
  • Pipeline definitions. A value written into a CI file instead of the platform’s secret store.

Most of these are shortcuts and not carelessness. They happen when the secure route is slower than the insecure one.

Layers, not a single control

Each control has a way around it. The aim is to place checks where they are cheap, and to make sure the later ones do not depend on the earlier ones working.

Layers of secret protection and the response pathFour layers on the commit path: workstation habits, a local pre-commit hook, a CI scan and repository controls. If a secret gets through, the response is to revoke or rotate it, check usage logs, fix the source, and clean history only if needed.COMMIT PATHWorkstationkeep values out of filesPre-commit hookscans staged changesCI scanscans history, fails jobRepository controlsrequired checklocal, can be skippedshared, enforceableserver-sideIF A SECRET GETS THROUGHRevoke or rotatefirst, alwaysCheck usageprovider audit logsFix the sourcemove to a secret storeClean historyonly if neededDetection comes after the mistake. Rotation ends the exposure; history cleanup does not.

Controls get stronger and harder to bypass further along the path. The response row applies whenever a layer misses.

LayerWhere it runsStrengthWeakness
Pre-commit hookDeveloper machineFast feedback, before the secret enters historyLocal, optional and easy to skip
CI scanShared pipelineSame check for everyone, can block a mergeThe secret is already pushed to a branch
Platform push protectionHosting serviceBlocks supported patterns before they landLimited to patterns the platform knows; availability depends on your plan
Scheduled scanShared pipelineRe-checks history when rules changeFinds problems after the fact

Push protection belongs to the hosting platform and not to Gitleaks. GitHub describes it as limited to patterns its secret scanning recognises, unless custom patterns are defined. It complements a scanner you control and does not replace one.

Detection and prevention are different jobs

Everything above detects secrets that have already been written into a file. Prevention means they are never in a file at all: values supplied at runtime from a secret manager or from the platform’s own workload identity, short-lived credentials in place of long-lived ones, and local-only config files listed in .gitignore. Prevention reduces how often the scanner has anything to find. The scanner covers the cases where the habit fails.

Running Gitleaks

Installation options in the project’s README include Homebrew (brew install gitleaks) and a Docker image, and you can also build from source. The commands below use the current command set. Version 8.19.0 deprecated detect and protect in favour of git, dir and stdin. The old commands still work but are hidden from help, so many older guides still show them.

# Scan the Git history of the repository in the current directory
gitleaks git -v .

# Scan files on disk, without looking at Git history
gitleaks dir -v .

# Scan only staged changes, as a pre-commit hook does
gitleaks git --pre-commit --redact --staged --verbose

# Write a SARIF report; --redact masks the secret in output
gitleaks git --redact --report-format sarif --report-path gitleaks.sarif .

The exit code is what makes it usable in automation: 0 means nothing was found, and 1 means leaks were found or an error occurred. The --exit-code flag changes the code returned when leaks are found.

Output is a finding for each match. Reports can be written as JSON, CSV, JUnit or SARIF. Redaction matters more than it looks, because a report that prints the secret it found becomes a second copy of it. Use --redact for anything that goes into shared logs or stored artifacts.

Finding fieldWhat it tells you
RuleID, DescriptionWhich rule matched, and what kind of credential it believes it is
File, StartLine, EndLineWhere in the file the match sits
Commit, Author, Date, MessageFor Git scans, which commit introduced it, and so who can say whether it is real
EntropyHow random the matched text is, which helps separate real keys from placeholders
FingerprintA stable identifier for the finding, used when ignoring one specific result

The pre-commit hook

The project ships a hook for the pre-commit framework. It runs the staged-changes scan shown above before each commit.

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.30.1   # pin a release tag; "pre-commit autoupdate" moves it forward
    hooks:
      - id: gitleaks
pre-commit install

# Skipping the hook is possible, and should stay a visible, deliberate act
SKIP=gitleaks git commit -m "message"

If you do not use the framework, a plain Git hook does the same job.

#!/bin/sh
# .git/hooks/pre-commit  (make it executable: chmod +x .git/hooks/pre-commit)
gitleaks git --pre-commit --redact --staged --verbose

The limitation is structural. A hook runs on the developer’s machine, so each person has to install it. Files in .git/hooks are not part of the repository and are not cloned, and the framework’s configuration is shared but still needs pre-commit install on every machine. It can also be bypassed deliberately or by accident, and the hook only looks at staged changes. That makes it a convenience and not an enforcement point. Its real value is that it catches the mistake before the secret enters history, where nothing else is as cheap.

Scanning in CI/CD with GitHub Actions

CI is where the check becomes shared and enforceable. This is the workflow from the action’s README, trimmed to the parts that matter here.

name: gitleaks
on:
  pull_request:
  push:
  workflow_dispatch:
jobs:
  scan:
    name: gitleaks
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
        with:
          fetch-depth: 0          # full history, not a shallow clone
      - uses: gitleaks/gitleaks-action@v3
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}   # organisation accounts only

Three details are worth calling out:

  • Full history. fetch-depth: 0 fetches every commit. A shallow checkout contains only recent commits, so a history scan would see only those.
  • Licensing. According to the action’s README, a GITLEAKS_LICENSE secret is required for organisation accounts and not for personal accounts, and GITHUB_TOKEN is supplied automatically. That requirement is described for the action. Check the README for current terms before adopting it.
  • Making it count. A scan that can fail but is not a required check on the protected branch is advisory. Mark it as required so a failing scan blocks the merge.

The action is one way to run the scan. In any other CI system you can install the binary and call gitleaks git, since a non-zero exit code fails the job.

Configuring rules

Gitleaks looks for configuration in a fixed order: the --config flag, the GITLEAKS_CONFIG environment variable (a file path), GITLEAKS_CONFIG_TOML (the content itself), a .gitleaks.toml in the scanned path, and finally the built-in default.

# .gitleaks.toml
title = "Repository secret rules"

[extend]
useDefault = true          # keep the built-in rules and add to them

[[rules]]
id = "acme-internal-token"
description = "Internal service token (synthetic example format)"
regex = '''acme_tok_[A-Za-z0-9]{32}'''
keywords = ["acme_tok_"]   # cheap pre-filter before the regex runs

  [[rules.allowlists]]
  description = "Obviously synthetic values in test fixtures"
  paths = ['''tests/fixtures/.*''']

The [extend] block is what pulls the built-in rules into your file. After any configuration change, test it against a deliberately synthetic value in a scratch directory and confirm the scan still reports it. A config that quietly stops detecting things is worse than none, because it looks like protection.

One compatibility note. Rule-level allowlists use [[rules.allowlists]] from v8.21.0, and earlier versions used [rules.allowlist]. Global [[allowlists]] blocks need v8.25.0 or later. Check the version your pipeline installs before copying configuration from an older post.

False positives, allowlists and baselines

Any scanner that matches patterns will flag things that are not secrets, and a team that is not given a way to say so will start ignoring the tool. The answer is a short, disciplined triage.

Real credentialFinding→Revoke and rotate→Remove from source→Add a rule or test
Test fixture or documentation exampleFinding→Make the value obviously synthetic→Allowlist narrowly, with a reason
Old findings when adopting the toolFindings→Triage, rotate the real ones→Baseline the remainder→New findings fail the build

Triage order for a finding. The first question is always whether the credential is real.

MechanismScopeGood forRisk
Inline gitleaks:allow commentOne lineOne known synthetic valueEasy to add without anyone reviewing it
.gitleaksignore fingerprintOne findingOne specific, reviewed resultTied to a particular finding, so it needs upkeep as code moves
Rule or global allowlistA pattern: paths, regexes, stopwords, commitsRecurring fixtures and generated filesA broad regex or path can hide real secrets
BaselineEverything in a reportAdopting the tool on an existing repositoryHides every finding it contains, real ones included

Four habits keep these under control:

  • Narrowest first. Allow one finding before a file, and a file before a directory. Never allowlist a source directory as a whole.
  • Write down why. Use the allowlist’s description field or the pull request, so a later reader can tell a reviewed decision from a silenced alarm.
  • Never allowlist a real credential. If it is real, it gets rotated. An allowlist entry is only for values that are not secrets.
  • Review them. Allowlists only grow unless someone removes entries, and stale ones can cover things they were never meant to.

Baselines have a trade-off

Switching on a scanner in a repository with years of history can produce hundreds of findings. Blocking every build on them stops adoption, so a baseline lets you record today’s findings and fail only on new ones.

# Record today's findings once, after triage
gitleaks git --report-path gitleaks-baseline.json .

# From then on, report only findings that are not in the baseline
gitleaks git --baseline-path gitleaks-baseline.json --report-path findings.json .

The trade-off is that the baseline hides everything in it. Triage first, rotate whatever is real, and baseline only the remainder, with a note saying what the baseline contains and why.

What Gitleaks cannot guarantee

Because it is rule-based, Gitleaks is bounded by its rules and by what you point it at.

It can reasonably help with

  • Credential formats that a rule describes, including rules you add.
  • Catching a mistake at commit or review time.
  • Scanning the history of repositories you point it at.
  • A consistent check that does not depend on a reviewer noticing.

It cannot promise

  • Formats no rule covers, until you add one.
  • Values with no recognisable shape or context, such as a short human-chosen password with an unremarkable variable name.
  • Encoded values, unless decoding is turned on. Decoding of percent, hex and base64 is controlled by --max-decode-depth, which defaults to 0.
  • Content inside archives, unless --max-archive-depth is set.
  • Anything you do not scan: chat, tickets, images, container layers, build logs and other repositories.
  • Telling you whether a credential is live or has already been used.

A clean scan means no rule matched. It does not mean there are no secrets. The opposite also holds: a match is a candidate that someone has to judge, because a rule can flag a value that is not a secret.

If a real secret has already been committed

Write these steps down before you need them, because the order matters.

  1. Treat it as exposed, and revoke or rotate it first. GitHub’s guidance says that for a secret, the first step is to revoke or rotate it, and that doing so may be enough to solve the problem. Once the old value stops working, the exposure ends. Do this before anything involving Git.
  2. Work out the exposure. How long was it in the repository, who could read the repository, and did it reach forks, CI logs or stored artifacts? Then check the provider’s audit logs for use of that credential during the window.
  3. Replace it properly. Put the new value in a secret store or inject it at runtime, scope it to the least access it needs, and change the code so the value is no longer in a file.
  4. Decide whether to rewrite history. Rewriting is optional once the credential is dead, and it has limits. GitHub’s documentation warns that after a rewrite and force push, the old commits may still be reachable in clones and forks, directly by SHA through cached views, and through pull requests that reference them. It recommends git-filter-repo and names a minimum version for its sensitive-data option, so follow its current guide and not a command copied from an older post.
  5. Close the loop. Add a rule, a test or a hook so the same kind of value is caught next time, and record why the earlier layers missed it.

Deleting the secret in a later commit does not help. The latest commit looks clean, but every earlier commit still contains it, and a scan of full history will go on reporting it. It also shows why a baseline must never be used to hide a real credential.

Key takeaways

  1. Treat any committed secret as exposed. Revoke or rotate before cleaning history.
  2. Layer the controls. A hook gives fast feedback, CI gives enforcement, and neither stands alone.
  3. Detection finds secrets after they are written. Prevention keeps them out of files.
  4. Keep allowlists narrow and justified, and baseline only after triage.
  5. A clean scan means no rule matched, and it is not proof that nothing is there.

References

Commands, flags and configuration syntax were checked against the project documentation when this was written. Gitleaks and its action change, so confirm against the current README before relying on any detail.

← Back to all Insights

More from Insights

Keep reading