Every layer in this cluster misses something. This page is about arranging them so that what one misses, another catches.
The word for that is defence in depth, and the useful definition is narrower than the usual one:
Layers provide depth only when they fail independently.
Three scanners running the same regular expressions are one layer bought three times. A local hook, a server-side block, a diff-scoped CI check and a scheduled history scan are four layers with genuinely different failure modes.
The stack
Section titled “The stack”A vertical chain: developer, pre-commit scan, push protection, pull request, CI secret scan, merge, continuous repository scanning.
Each layer’s failure mode, which is what justifies the next one:
| Layer | Runs | Fails when |
|---|---|---|
| Pre-commit hook | Developer’s machine | Not installed, or --no-verify |
| Push protection | GitHub, on push | Pattern not supported, or bypassed |
| Pull request scan | CI, on the diff | The secret is in history, not this diff |
| Continuous scan | CI, on a schedule | Retrospective — the credential already leaked |
| Rotation | Provider | Nothing catches it, so it expires instead |
Reading down that column is the argument for the stack. Reading across it is the argument against believing any single row.
Layer 1: pre-commit
Section titled “Layer 1: pre-commit”The cheapest place to catch a mistake, because nothing has been committed yet.
repos: - repo: https://github.com/gitleaks/gitleaks rev: v8.30.1 hooks: - id: gitleaksTwo structural limits, neither of them fixable:
It is not installed by default. A fresh clone has no hooks until somebody runs pre-commit install. Adding it to your project’s setup script helps; it will still be missing somewhere.
It is skippable. SKIP=gitleaks, --no-verify, or committing from a tool that does not run hooks.
Both are why this is a layer and not a control. Its value is real — it is the only layer that catches the problem before there is a commit to rewrite — and it should never be the thing you are relying on.
Layer 2: push protection
Section titled “Layer 2: push protection”Server-side, unskippable by local configuration, and the highest-value single setting in the cluster. Covered in full in Push protection.
Two things to get right for the stack to work:
Write custom patterns for your own credential formats. Push protection blocks what secret scanning can match. Your internal service token is not on that list until you describe it.
Enable delegated bypass once the block rate is low enough that approval is not a bottleneck. Self-service bypass makes the control advisory for exactly the people most motivated to get past it.
Layer 3: the pull request scan
Section titled “Layer 3: the pull request scan”A CI check on the pull request, using a different engine from GitHub’s.
The point is not redundancy for its own sake. GitHub’s scanning uses its own pattern set; GitLeaks uses another, with configurable rules of your own; TruffleHog verifies against providers. A credential format one covers and another does not is the entire reason for this layer.
name: secret scan
on: pull_request:
permissions: contents: read
jobs: scan-diff: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: fetch-depth: 0
- name: Install gitleaks run: | curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/gitleaks_${VERSION}_linux_x64.tar.gz" \ | tar -xz gitleaks sudo mv gitleaks /usr/local/bin/ env: VERSION: 8.30.1
- name: Scan the pull request range run: | gitleaks git --redact --no-banner -v \ --log-opts="${BASE}..${HEAD}" . env: BASE: ${{ github.event.pull_request.base.sha }} HEAD: ${{ github.event.pull_request.head.sha }}Four decisions in that file are worth stating explicitly.
fetch-depth: 0. A shallow clone has no history. A history scan against it finds nothing and
passes — a green check that looked at nothing, which is worse than no check at all.
The scan is scoped to the pull request range. Reporting the repository’s entire historical backlog on somebody’s unrelated change is how a check gets removed. Scope it to what the author introduced.
--redact. Without it, findings print into the run log, which is readable by everyone with access
to the repository and retained.
The version is pinned. An unpinned installer means the scanner changes without a change on your side, and a new rule surfaces as a failure on an unrelated pull request.
Layer 4: continuous scanning
Section titled “Layer 4: continuous scanning”A scheduled full-history scan, with verification, that does not block anything.
scan-history: if: github.event_name == 'schedule' runs-on: ubuntu-latest permissions: contents: read security-events: write steps: - uses: actions/checkout@v7 with: fetch-depth: 0
- name: Full history scan run: | gitleaks git --redact --no-banner --exit-code 0 \ -f sarif -r gitleaks.sarif .
- uses: github/codeql-action/upload-sarif@v4 with: sarif_file: gitleaks.sarifThree deliberate choices:
--exit-code 0 so a finding does not fail the job before the results are uploaded. This job’s
purpose is producing a triageable list, not blocking. The blocking job is the pull request one.
SARIF upload so findings land in the repository’s security tab alongside everything else, rather
than in a log nobody opens. This requires security-events: write, which is why the permission block
is on this job and not the other.
A schedule, because this scan is slow and there is no point running it on every push. Daily is usually right.
Pair it with a TruffleHog run for verification, which converts the list into “these three are live” rather than “here are two hundred strings”.
Adding verification to the pipeline
Section titled “Adding verification to the pipeline”The layers above all answer “does this look like a credential?” Adding TruffleHog to the scheduled job answers “is it live?”, which is what turns a list into a queue you can prioritise.
- name: Install TruffleHog run: | curl -sSL "https://github.com/trufflesecurity/trufflehog/releases/download/v${VERSION}/trufflehog_${VERSION}_linux_amd64.tar.gz" \ | tar -xz trufflehog sudo mv trufflehog /usr/local/bin/ env: VERSION: 3.97.1
- name: Verify findings run: | trufflehog git file://. --results=verified --json --no-update > verified.json count=$(jq -s 'length' verified.json) echo "### Verified credentials: ${count}" >> "$GITHUB_STEP_SUMMARY" if [ "$count" -gt 0 ]; then jq -r '"- \(.DetectorName): \(.Redacted)"' verified.json >> "$GITHUB_STEP_SUMMARY" exit 1 fiThree things that make this safe to run:
Only --results=verified. Unverified findings belong in the GitLeaks SARIF report, where they can
be triaged without urgency. This step exists to surface the small set that is live.
Redacted, never Raw. The step summary is as visible as the log.
It fails. Unlike the GitLeaks reporting scan, a verified credential in your repository is worth failing a job over, even a scheduled one — because somebody needs to see it today.
One caveat to plan for: verification authenticates with the candidate credential, so the providers involved see an authentication attempt from a GitHub-hosted runner. If your organisation alerts on unfamiliar authentication sources, tell the people who watch those alerts before the first scheduled run.
Scaling it across an organisation
Section titled “Scaling it across an organisation”A pipeline in one repository is a demonstration. Getting it into fifty is a different problem, and copying the workflow file fifty times is the wrong answer — the copies diverge, and nobody can tell which repositories have which version.
Three mechanisms, in increasing order of strength:
A reusable workflow. Define the scanning jobs once and call them:
jobs: secrets: uses: YOUR_ORG/.github/.github/workflows/secret-scan.yml@v1One definition, one place to update, and each repository’s file is three lines. Reusable workflows covers the mechanics.
A required status check in an organisation ruleset. This is what stops a repository opting out quietly. Target repositories by custom property and require the check by name — see Repository rulesets for security.
Organisation-level enablement for the native features. Secret scanning and push protection are configured through a security configuration applied across repositories, not repository by repository. That is the layer with the widest coverage for the least effort, and it should be first.
The order matters: enable the native features organisation-wide, then add the reusable workflow, then require it. Requiring a check before it exists everywhere produces blocked pull requests in repositories whose owners have not heard of it.
Handling findings
Section titled “Handling findings”A pipeline that produces findings nobody works is a pipeline with the security value of no pipeline and the cost of four jobs.
Blocking findings — the pull request check — go to the author, in the pull request, with an actionable message. The author caused it, can fix it, and is looking at the change right now. That is the entire reason this layer is positioned here.
Non-blocking findings — the scheduled scan — need an owner and a queue. Not a tab. Route them to whoever owns the repository, with a triage cadence somebody actually keeps.
Verified findings are incidents. Rotate first, following Rotating exposed credentials.
The triage order that works, in every case:
- Is it live? TruffleHog answers this in seconds. Everything else is secondary.
- What does it reach? This determines severity, not the pattern name.
- Rotate, if it is live or you cannot tell.
- Remove it from current files so it stops recurring.
- Suppress or fix the finding with a narrow, documented allowlist entry if it is genuinely a false positive.
- Decide about history, last and optionally.
Where credentials actually enter, and which layer catches each
Section titled “Where credentials actually enter, and which layer catches each”The stack is easier to evaluate against real routes than against a diagram. These are the paths that recur, with the layer that closes each.
| Route in | Caught by |
|---|---|
.env staged by git add . | Pre-commit hook; .gitignore if it was never tracked |
| A real token in a test fixture | Pull request scan, if a rule matches the format |
| A key pasted into an issue comment | GitHub secret scanning — no other layer sees issues |
| Credentials in a config file added by a fork contributor | Pull request scan; push protection on the fork’s push |
| A token in a commit message | Push protection and the history scan; not the diff scan, which reads content |
A .env copied into a container image | None of the above — only a container scan |
| A credential in a Terraform state file committed by CI | Pull request scan, if *.tfstate is not already ignored |
| An internal service token with a bespoke format | Nothing, until somebody writes a custom pattern |
Two rows deserve attention because they are the gaps in an otherwise complete stack.
Container images are outside every Git-based layer. A credential baked in at build time never
entered the repository, so nothing above sees it. trufflehog docker --image=... against published
tags is the layer that covers this, and it belongs in the release pipeline rather than in this
workflow.
Bespoke credential formats are the most likely thing your organisation will actually leak, and the
only layer that covers them is one you write. Custom patterns for push protection, plus custom rules
in .gitleaks.toml, are the highest-value tuning available — and both are usually skipped because
they require somebody to know what the organisation issues.
The emergency bypass
Section titled “The emergency bypass”Every blocking control needs a documented way past it, because the alternative is that somebody invents one under pressure and nobody records what happened.
A bypass process that does not undermine the control:
It exists and is written down. An undocumented bypass is discovered during an incident by somebody guessing, which is the worst possible time to be improvising.
It is visible. Using it creates a record — an alert, a message in a channel, an issue. GitHub’s push protection bypasses are recorded; a CI bypass needs you to record it.
It requires a reason. Not a dropdown: a sentence somebody else will read.
It has a follow-up. A bypass creates an item that must be closed. “I’ll fix it later” is only acceptable when there is a later that somebody owns.
It is rare. If the bypass is used weekly, the control is miscalibrated. Fix the control rather than normalising the exception.
Keeping secrets out of logs
Section titled “Keeping secrets out of logs”The pipeline itself handles credentials, and its logs are a disclosure surface.
GitHub Actions masks registered secrets in log output, replacing them with ***. That protects
values passed through secrets.*, and it does not protect a credential the job derived, decoded or
fetched at run time.
Register anything sensitive you compute:
{/* Mask a value the job obtained itself, so it is redacted in the log */}echo "::add-mask::${DERIVED_TOKEN}"Do not enable debug logging on jobs that touch credentials as a matter of routine. Step debug logging is genuinely useful and it prints a great deal more of the environment.
Remember artifacts. A build artifact containing a .env, a config dump or a scan report is
downloadable by anyone with repository access, and it persists after the log rotates.
Testing the pipeline
Section titled “Testing the pipeline”A detection pipeline nobody has tested is a set of jobs that pass. Confidence comes from watching each layer fire.
Keep a small canary: a randomly generated string in a recognised format, held outside the repository, that you deliberately introduce into a disposable branch once a quarter. Push it, open a pull request, and confirm each layer behaves as documented. Then delete the branch.
The point is not to find bugs in the scanners. It is that layers get disabled quietly — a workflow
renamed, a required check that stopped reporting, a fetch-depth that reverted to the default in a
refactor. None of those produce an error; they produce a passing check that examines nothing.
Do the same test with your organisation’s own token format. If nothing fires, you have found the gap that matters most, because that is the credential you are most likely to leak.
Measuring the pipeline
Section titled “Measuring the pipeline”Three numbers, each answering a different question.
Blocks per week. Every one is an incident that did not happen. This is the number that justifies the pipeline’s cost.
Escapes. Credentials found by the scheduled scan that the earlier layers missed. Each escape names a specific gap — usually a pattern nobody wrote, or a layer not enabled on that repository.
Time from detection to rotation. The only number that measures the response rather than the detection. If it is measured in days, the pipeline is producing findings into a queue nobody works.
The third is the one to watch. A pipeline with excellent detection and a week-long rotation time has converted an unknown risk into a known one and stopped there.
Common mistakes
Section titled “Common mistakes”Running the same engine at every layer. Three copies of one pattern set is one layer, three times.
Shallow clones. No history to scan, and a green check that looked at nothing.
Scanning full history on every pull request. Slow, and it reports findings the author did not cause, which is how the check gets removed.
Unredacted output in logs. The scanner publishes the secret to everyone with repository access.
A blocking check with no bypass. Somebody invents one under pressure, and it is not recorded.
A bypass that produces a green check. Indistinguishable from a scan that passed.
No owner for the scheduled scan. Findings accumulate in a tab, and the tab becomes evidence that detection is working while nothing is being fixed.
Measuring detection rather than remediation. The number that matters is time to rotation.
Mental model
Section titled “Mental model”The stack is not four chances at the same catch. Each layer is positioned where a different failure mode lives — a skipped hook, an unmodelled pattern, a secret already in history, a credential nobody found at all. Depth comes from the differences, not from the count.
What you learned
Section titled “What you learned”- Layers give depth only when they fail independently; identical scanners are one layer repeated
- Pre-commit is cheapest and skippable; push protection is server-side and pattern-limited
- The pull request scan should use a different engine and be scoped to the pull request’s commit range
fetch-depth: 0is mandatory, or the history scan silently examines nothing- The scheduled scan should not block, and should upload SARIF so findings land in the security tab
--exit-code 0on a reporting scan lets the upload step run- Scanner output must be redacted before it reaches a CI log
- A bypass must exist, be visible, require a reason, create follow-up work, and be rare
- A bypassed check must not look like a passing check
- The metric that matters is time from detection to rotation, not number of findings
Exercise
Section titled “Exercise”Build the stack on a disposable public repository, one layer at a time, and test each one.
-
Install the GitLeaks pre-commit hook. Commit a randomly generated fake
ghp_token. Predict: does the commit succeed? -
Bypass with
SKIP=gitleaks git commit. Predict: which layer catches it next? -
Push. Predict: does push protection block it, and what does it say?
-
Bypass push protection with “I’ll fix it later”. Check whether an alert was created and whether it is open or closed.
-
Add the pull request scan workflow. Open a pull request adding a second fake token. Predict: does the check fail, and does the log show the value?
-
Remove
--redactand re-run. Predict: what is now in the log, and who can read it? -
Change
fetch-depth: 0to the default and re-run. Predict: does the check still catch anything? -
Add the scheduled job with SARIF upload and run it manually. Confirm findings appear in the security tab.
-
Delete the repository.
Related lessons
Section titled “Related lessons”The secrets management checklist and least-privilege token guide are in the Professional Toolkit.