CI Security
The pipeline is a privileged production identity that executes code from anyone who can open a pull request — and those two facts have to be kept apart.
The question, the obvious approach, and why it breaks
Every lesson starts where the work starts: an operational problem, a first attempt that is entirely reasonable, and the way production disagrees with it.
What can an attacker do with our CI system, and what is the smallest set of privileges each job actually needs?
CI has to hold credentials to be useful — registry, cloud, package publishing — and it has to run code proposed by contributors to be useful. Every serious CI compromise lives in the overlap.
Store the deploy credentials as repository secrets so any job can use them, run one workflow for everything, and let the pipeline deploy on merge. It works and it is simple.
A single credential set available to every job means every job — including the one that runs a dependency's post-install script — can reach production. A runner with broad production credentials is one of the listed CI/CD anti-patterns for exactly this reason (CI/CD Anti-Patterns).
- A single credential set available to every job means every job — including the one that runs a dependency's post-install script — can reach production. A runner with broad production credentials is one of the listed CI/CD anti-patterns for exactly this reason (CI/CD Anti-Patterns).
- Pull requests execute contributor-authored code: the test files, the build scripts, and every dependency's lifecycle hooks. If secrets are present in that job, they are readable by that code.
- Long-lived static cloud keys stored as secrets do not expire, are copied into environment variables, and appear in
envdumps, crash handlers and debug output. - Third-party actions and plugins referenced by a mutable tag execute with whatever the job holds. Re-tagging is an update path for the publisher and an injection path for anyone who compromises them (Typosquatting and Malicious Packages).
- Self-hosted runners that persist between jobs let one job leave state — a modified tool on the PATH, a poisoned cache, a background process — that the next job inherits (Build Environments).
- The publish step is usually the highest-privilege job in the organisation and is usually written by whoever set up the repository, once, and never reviewed again.
What is actually happening
Underneath the tooling, which is the part that survives a change of tool.
- CI sits on a trust boundary that most mental models miss: the code being tested is untrusted input to a trusted system. Every design decision here follows from taking that literally (Trust Boundaries).
- Trigger type is the boundary control. Systems distinguish a run of untrusted code without secrets from a run of trusted code with them, and the distinction is expressed differently — and confusingly — in each tool.
- Credential lifetime is the second control. A short-lived token minted per job for a specific audience cannot be exfiltrated usefully, because by the time it is used elsewhere it has expired (Short-Lived Credentials).
- Scope is the third: the job that runs tests needs read access to the repository and nothing else; the job that publishes needs write access to one registry path and nothing else. These should be different identities, not the same one used twice.
- Runner isolation is the fourth. An ephemeral runner destroyed after each job cannot carry state forward; a persistent one is a shared machine whose previous occupant may have been hostile (Sandboxing Untrusted Workloads).
- Finally, a human gate on the privileged transition — the deploy, the publish — is not bureaucracy. It is the one control that does not depend on any of the above being configured correctly (Production Access).
The pipeline as a production identity
Drawn as a diagram, CI stops looking like a build tool and starts looking like what it is: a service with credentials to your registry, your cloud account and your package namespace, which accepts arbitrary code from the internet as input.
The dotted edge is the one that decides everything. If untrusted code and privileged credentials meet in the same job, no other control matters.
The pull-request boundary
This is the specific mechanism that has produced the most real incidents, and it is genuinely confusing because the triggers have similar names and materially different security properties.
Read the table for your own CI system before trusting any of it. The shape of the question — who can cause this to run, and what does it hold while running — transfers; the answers do not.
| GitHub Actions trigger | Who can cause it | Code that runs | Secrets available |
|---|---|---|---|
push to a branch | Someone with write access | Trusted, reviewed | Repository secrets |
pull_request, same repo | Someone with write access | Branch code, pre-review | Repository secrets |
pull_request, fork | Anyone on the internet | Untrusted | None; token is read-only |
pull_request_target, fork | Anyone on the internet | Base repo workflow — unless you check out the PR head, which is the classic mistake | Full repository secrets |
workflow_run | Completion of another workflow | Base repo workflow | Full secrets; must not trust artefacts from the untrusted run |
schedule | The clock | Default branch | Full secrets |
Scoping the credential
Once untrusted code is separated from privileged jobs, the remaining question is what the privileged job holds. There are four common answers and they differ by an order of magnitude in what a compromise yields.
The publish job needs to write to a registry. What credential does it use?
when The target genuinely has no federation support. Increasingly rare and worth checking before assuming.
cost Never expires, exists in a store someone can read, and a single leak is valid until someone notices and rotates (Rotation That Applications Survive).
when The default for any cloud provider that supports it.
cost Trust policy configuration is subtle — a condition matching the organisation rather than the specific repository and ref grants far more than intended.
when Always, layered on the above: the identity that publishes should not be the identity that tests.
cost More identities to manage, and a job boundary means passing artefacts between jobs, which is its own integrity question (Tags Versus Digests).
when Production deploys, package publishing, anything irreversible.
cost Latency on every release including urgent ones, and approval fatigue if the gate fires too often to be read (Change Management).
1permissions:2 contents: read # workflow-wide default: read nothing else3 4jobs:5 test:6 runs-on: ubuntu-latest # inherits contents: read, and holds nothing else7 steps:8 - uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b39 - run: npm ci --ignore-scripts10 - run: npm test11 12 publish:13 needs: test14 if: github.ref == 'refs/heads/main'15 environment: production # can require a named human reviewer16 permissions:17 contents: read18 id-token: write # mint an OIDC token; no stored cloud key exists19 packages: write # push to one registry namespace20 runs-on: ubuntu-latest21 steps:22 - uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b323 - run: ./publish.shThree separate controls are doing work here. The digest-pinned action cannot be swapped by re-tagging. --ignore-scripts stops a dependency's lifecycle hooks executing during install in the job that runs untrusted code. And id-token: write exists only on the publish job, so nothing else in the workflow can mint a cloud credential at all.
How to do it properly
Most important first.
- Default the workflow token to read-only and grant additional permissions per job, at the narrowest scope that job needs.
- Use workload identity federation instead of stored cloud keys: the job proves its identity to the cloud provider and receives a short-lived credential scoped to a role (Workload Identity).
- Never expose production secrets to a run triggered by an untrusted contributor. Split the workflow: untrusted code runs without secrets, and privileged work happens in a separate job on a trusted trigger.
- Pin third-party actions and plugins to an immutable commit digest, not to a tag (Dependency Pinning).
- Make publishing and deployment separate jobs with separate identities, and put a human approval on the environment they target (Promotion).
- Prefer ephemeral runners. If self-hosted runners are required, do not let them serve fork pull requests, and destroy them between jobs.
- Log privileged CI actions to an audit trail that CI itself cannot edit (The Audit Trail, Audit Logs for Privileged Actions).
- Rotate what CI holds on the same schedule as anything else, and rehearse it (Rotation That Applications Survive).
How much can this affect
Every production change has a blast radius. Stated as a scale so it is comparable between changes rather than adjectival — and paired with what actually contains it, because a wide scope with a real containment mechanism is a different situation from a wide scope with none.
A compromised privileged CI job can publish artefacts every environment will pull. Containment is credential scope, artefact signing and provenance verification at deploy time (Signing and Verifying Artifacts, The Builder Is Inside the Trust Boundary).
What can go wrong
- Secrets exposed to a fork pull request through a misunderstood trigger, which has been the source of repeated real-world compromises.
- A compromised third-party action reading every secret in the job environment and exfiltrating it over an ordinary HTTPS call that no egress policy blocks (Egress Security).
- Cache or artefact poisoning from an untrusted job into a trusted one (Caching in CI).
- Secret masking relied on as a control: it redacts exact string matches in logs and does nothing about base64, reversal, or writing to a file (When Secrets Fail).
- A break-glass credential left in CI after an incident, permanently.
- Branch protection that requires review for code and not for the workflow file that decides what "review" means (Protected Branches).
- OIDC federation configured with a trust condition so loose that any repository in the organisation — or any fork — can assume the production role.
- "Secrets are encrypted at rest, so they are safe." They are decrypted into the environment of a job that runs code you did not write. Storage was never the exposure.
- "Only maintainers can run workflows on our repository." Only maintainers can *approve* them on many forges, and approval is often per-contributor rather than per-change. Read your tool's actual rule.
- "We scan dependencies, so the supply chain is covered." Scanning finds known-vulnerable versions. It does not stop a build that has permission to publish from publishing something it should not (The Delivery Chain as Attack Surface).
- "The runner is ephemeral, so isolation is handled." Ephemeral covers state between jobs. It does nothing about what the job can reach while it is running.
Operating it
Evidence is the signal, not the intention. Rollback is sometimes 'you cannot, and that is the point'.
- You can enumerate every credential CI can reach, which job reaches it, and what it can do with it.
- A test job's environment, dumped deliberately, contains no production credential.
- Cloud audit logs show the CI role being assumed only from the expected repository, branch and workflow (The Audit Trail).
- Every third-party action in the repository resolves to a commit digest, checkable with a one-line grep.
- A rotation has actually been performed and the pipeline survived it.
- Deploys to production show an approving human, or a documented reason why the path is fully automated (Change Management).
- Revoke first, investigate second. Every credential the affected job could reach is considered compromised — revoke and re-issue rather than reasoning about whether it was actually read.
- Disable the affected workflow or trigger while investigating; a paused pipeline is a delivery problem, an active compromised one is an incident (What Happens Between the Page and the Postmortem).
- After any suspected compromise, distrust artefacts built in the window and rebuild from a clean environment, using provenance records to establish the boundary (Build Provenance).
- Tightening permissions is reversible and cheap to test — the failure mode is a broken job, which is visible immediately, unlike the failure mode of leaving them broad.
- Automate credential issuance: short-lived, per-job, per-audience tokens minted by the platform beat anything a human stores.
- Automate the checks — a policy that fails a pipeline whose workflow requests write permissions it does not need, or references an unpinned action (Policy as Code).
- Automate secret scanning on every push, and treat a hit as rotation-required rather than as a warning (Scanning, and Why a Finding Is Not a Risk).
- Keep the approval for privileged transitions human, and keep the ability to revoke a credential a one-command human action (Break-Glass Access).
- Least privilege means more identities, more configuration and more ways for a legitimate job to be blocked by a missing grant. That friction is the cost of the control and it is real.
- Splitting trusted and untrusted work means fork contributors get a weaker signal on their pull request, because the jobs needing credentials cannot run for them.
- Digest-pinned actions do not receive security fixes automatically; you have taken on the update duty in exchange for immutability (Dependency Pinning).
- Human approval on deploys adds latency to every release, including the fix during an incident — so the emergency path has to be designed rather than improvised.
Where this applies
This domain is unusually tool- and organisation-dependent. These labels say what each claim is specific to, and what a different platform, provider or organisation does instead.
- TOOL-SPECIFICGitHub Actions draws the boundary at the trigger:
pull_requestfrom a fork runs with a read-only token and no secrets, whilepull_request_targetruns in the context of the base repository with secrets available — and checking out the PR head under that trigger is the classic critical misconfiguration. GitLab draws it at protected branches and environments: protected variables are only exposed to jobs on protected refs. Jenkins draws it wherever you built it. The same words mean different guarantees. - GENERALThe principles — untrusted code must not meet privileged credentials, credentials should be short-lived and narrowly scoped, privileged transitions get a human — hold on every CI system regardless of how they are spelled.
Where the depth lives
This domain teaches delivery and operation, and hands the mechanism off to the domain that owns it.