Securing a CI/CD pipeline means reducing what it can do once compromised, across four surfaces: identities, dependencies, runners and artifacts. A pipeline holds secrets to production, writes the artifacts your users install, and runs third-party code nobody reviews. This lesson is for anyone who writes or maintains pipelines without being a security specialist. It gives a model that holds whatever the tool, GitHub Actions, GitLab CI or Jenkins, and points to the lessons that put it into practice.
What you will learn
- Explain why a pipeline is a more profitable target than a server
- Identify the four control surfaces and the risk specific to each
- Apply least privilege, pinning and runner isolation
- Tell a structural control from a point-in-time control
- Restrict the third-party code allowed to run and trace the CI's configuration changes
- Place a pipeline in a four-level maturity model
Why is a pipeline a target?
A CI/CD pipeline (continuous integration and delivery) is the system that tests, builds and deploys your code without human intervention. To do that job, it receives broad rights: read the code, publish artifacts, connect to production. An attacker who controls it inherits all those rights at once, without ever touching your servers.
That is the difference with a classic attack. Compromising a server gives access to that server; compromising the pipeline gives access to everything it ships, on every run. The usual picture is the water supply: you do not poison each glass, you poison the reservoir, and the poison reaches everyone.
What an attacker can obtain
The table below links each resource the pipeline handles to what an attacker would do with it. Read it as a list of what to protect: each row matches a control surface covered further down, and the last column shows that the impact always reaches beyond the pipeline itself.
| Resource | What the attacker can do | Concrete example |
|---|---|---|
| Secrets | Steal API tokens, SSH keys, cloud credentials | Use your own keys on your cloud account |
| Source code | Modify the code before compilation | Add a backdoor invisible to review |
| Artifacts | Replace a deliverable | Your users install a tampered version |
| Production | Deploy directly | Take over your servers |
The tj-actions/changed-files case, March 2025
The most instructive attack of recent years hit a GitHub action used by more than 23,000 repositories. On 14 and 15 March 2025, its version tags were moved to a malicious commit (CVE-2025-30066). That code read the runner memory, extracted the secrets, and wrote them base64-encoded into the workflow logs.
On a public repository, those logs are readable by anyone: the secrets were exposed without contacting any external server. The affected teams had changed nothing, since they referenced the action by tag, and that tag had moved under their feet. Only the repositories that pinned the action by SHA did not run the malicious code. The fix shipped in version 46.0.1.
What are the four control surfaces?
Pipeline security plays out on four surfaces, and each one matches a trust decision that must be explicit and verifiable. The table sums up the question to ask for each one and what happens when it is ignored. The following sections cover them one by one, from the simplest to set up to the most structural.
| Surface | Key question | Risk if neglected |
|---|---|---|
| Identities | Who may do what, and for how long? | Excessive access, stolen secrets |
| Dependencies | Which external code do we trust? | Malicious code executed |
| Runners | Where does the code run, and what does the machine keep? | Contamination between jobs |
| Artifacts | How do we prove where the deliverable comes from? | Shipping a tampered deliverable |
Surface 1: identities and permissions
The question is not whether the pipeline must authenticate, but with what and for how long. It connects to an image registry, a cloud provider, a deployment tool, and each connection relies on a credential. A long-lived secret stored in CI remains valid after its author leaves, and after the leak that exposed it.
Least privilege, job by job
Each job receives only the permissions strictly needed for its task. On
GitHub Actions, the automatic token is set by the permissions block: empty it
at workflow level, then grant rights job by job. A compromised test job
can then neither push code nor publish a package.
# ❌ Excessive permissions: a compromised job can do anythingpermissions: write-all
# Minimal permissions: nothing by default, then the bare minimum per jobpermissions: {}
jobs: publish: runs-on: ubuntu-24.04 permissions: contents: read # read the code, without being able to change it packages: write # publish a package, nothing elseEphemeral tokens rather than permanent secrets
An ephemeral token expires on its own after a few minutes; a static secret stays valid until someone revokes it, which is often forgotten. The OIDC protocol (OpenID Connect) lets the pipeline ask the cloud provider for a temporary token on each run, instead of storing a permanent key. The table shows what this choice changes on the day of a leak.
| Property | Static secret | Ephemeral token (OIDC) |
|---|---|---|
| Lifetime | Unlimited | A few minutes |
| If stolen | Usable until detected | Expires quickly |
| Revocation | Manual, often forgotten | Automatic |
The implementation on GitHub Actions is covered in the lesson OIDC with GitHub Actions.
One secret per environment
Staging and production secrets must be distinct, and stored in protected environments of the platform. A leak on the staging side then gives no access to production. Before configuring a job's access, four questions are enough: must this token write, or only read? Is OIDC possible? What do we lose if this secret is stolen? When was it last rotated?
Surface 2: dependencies
The code you write is only a fraction of what runs. Every npm install,
every pip install, every third-party action runs code from maintainers
you do not know, with your pipeline's permissions. The goal is to make what
the pipeline installs reproducible, and what changes detectable.
Pin by SHA, not by tag
A tag such as @v4 is mutable: its maintainer, or an attacker who took
over their account, can move it to another commit. A commit SHA is
immutable: it always designates exactly the same code. That is precisely
what protected the pinned repositories during the tj-actions attack.
# ❌ Mutable tag: may point to different code tomorrow- uses: actions/checkout@v4
# Immutable commit SHA, with the version as a comment for readability- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1The SHA to keep is the commit one, not the tag object one. The command
git ls-remote --tags https://github.com/actions/checkout v7.0.1 gives it; for
an annotated tag, take the line suffixed with ^{}, which designates the
commit pointed to. The full method is in the lesson
Pin actions by SHA.
Decide which third-party code may run
Pinning answers the question "will this code change?", not "am I
allowed to run it?". An allowlist set at the organisation level
closes the second one: only explicitly admitted actions run, whatever workflow
calls them, including a workflow added by a compromised account. On GitHub, the
"allow the organisation and a selection" policy accepts patterns such as
actions/* or sigstore/cosign-installer@*, and only exists at the level of an
organisation.
It is a structural control: it applies without depending on the YAML of each repository, which any contributor can modify. The two controls complement each other without replacing one another: an allowed action pinned by tag stays exposed to a rewrite, a pinned action that is not allowed should not run at all. The lesson Restricting allowed actions details the syntax.
Commit the lock files
A lock file (package-lock.json, poetry.lock) records the exact version
of each dependency, including transitive dependencies. The pipeline then
installs it as is, with npm ci rather than npm install, and produces the
same result on every run. The table compares the two situations.
| Without a lock file | With a lock file |
|---|---|
npm install takes the latest versions | npm ci installs exactly the recorded versions |
| Different result from one run to the next | Identical result on every run |
| An update lands without review | Updates go through review |
Audit before adding
A new dependency is a trust decision, not a technical detail. Before adding it, check who maintains it, for how long, how many projects use it, and whether its code is readable. A single maintainer is a risk, as is a recent package or obfuscated code. The OpenSSF Scorecard tool automates part of these checks.
| Criterion | Why it matters |
|---|---|
| Who maintains it? | A single maintainer is a breaking point |
| For how long? | A recent dependency has not proven itself |
| How many projects use it? | Few users, few eyes to spot a problem |
| Is the code readable? | Obfuscated code may hide something |
Surface 3: runners and isolation
The runner is the machine, physical or virtual, that runs your jobs. Whatever it keeps from one run to the next, cache, credentials, temporary files, becomes reachable by the next job, including when that job comes from an external contribution. The goal is to remove that persistence, the main vector of contamination from one job to another.
Ephemeral runners
An ephemeral runner is created for one job, then destroyed: nothing survives the run. A persistent runner, on the contrary, keeps the state left by each job, and malicious code can settle there to wait for the next deployment. The table follows two successive jobs in each case; the lesson Ephemeral runners gives the implementation.
| Persistent runner | Ephemeral runner |
|---|---|
| Job 1 modifies files | Job 1 runs, the runner is destroyed |
| Job 2 inherits those modifications | Job 2 gets a fresh runner |
| Malicious code can persist | Nothing can persist |
Strict network isolation
The runner only reaches the resources the job needs: the registry, the package repository, the deployment API. By default, it sees neither the internal network nor the company databases. A compromised job on an isolated runner can then neither explore the network nor exfiltrate data to an unexpected destination.
Keep external code away from secrets
When an external person opens a pull request from a fork, the code
they propose runs on your runners. That code must never reach your
secrets. On GitHub Actions, the pull_request trigger respects this rule:
a workflow started from a fork receives neither secrets nor a write token.
The trap comes from the pull_request_target trigger, which runs with the
target repository's secrets. If it checks out and runs the pull request
code, it hands those secrets to any contributor. The lesson
Securing pull_request_target
details the attack and the countermeasure.
Surface 4: artifacts and chain of trust
Between building an artifact and deploying it, nothing proves by default that it is the same content. An artifact is the pipeline's output: a container image, an executable, a package. Without a verifiable digest or a signature, a swap along the way is undetectable by whoever installs it.
Sign the artifacts
Signing an artifact means attaching cryptographic proof of its origin: if the artifact is modified after signing, verification fails. The consumer no longer has to trust the distribution channel, they verify for themselves. The reference tool for images is Cosign.
| Unsigned artifact | Signed artifact |
|---|---|
| Unknown origin | Verifiable origin |
| Uncertain integrity | Any modification is detected |
| "Trust me" | "Check for yourself" |
Provenance attestations and SBOM
A provenance attestation describes how the artifact was built: which commit, which pipeline, which environment. The SLSA (in French) framework sets its levels of assurance. An SBOM (Software Bill of Materials) is the full list of the artifact's components, like a list of ingredients.
These three proofs answer three different questions. The signature says who produced the artifact, the attestation says how, and the SBOM says what it contains. The day a vulnerability like Log4Shell is published, the SBOM tells you within seconds which artifacts are affected.
Structural or point-in-time control: which one to favour?
A structural control makes an action impossible by construction; a point-in-time control checks it at a given moment. The first is sturdier, because it depends neither on a step that can be removed nor on an alert that can be ignored. A vulnerability scan, a static analysis or a security test on a pull request are point-in-time controls: useful, but they can be bypassed, disabled, or simply forgotten.
The table gives five structural controls and what they make impossible. Point-in-time controls remain additional safety nets, not ramparts. The lesson Blocking a pipeline does not improve security shows what happens when we expect more from them than they can give.
| Structural control | What it makes impossible |
|---|---|
| Minimal permissions by default | Getting more rights than planned |
| Ephemeral runners | Making malicious code persist |
| Signature required at deployment | Deploying an unverified artifact |
| OIDC instead of static secrets | Stealing a permanent credential |
| Allowlist of actions | Running an unapproved third-party action |
Who changed the pipeline? The audit log
The Git history traces workflow files, not the platform's settings. A replaced secret, a loosened actions policy, a self-hosted runner added to the organisation: none of this shows up in a commit. These changes live in the platform's audit log, and it is the audit log that answers, on the day of an incident, the question "who loosened this control, and when?".
The audit log prevents nothing: it is a detection and investigation control, worth something only if it is read. On GitHub, four events sum up what a security team should watch on the CI:
| What changes | GitHub event |
|---|---|
| The organisation's Actions policy | org.update_actions_settings |
| An organisation Actions secret | org.update_actions_secret |
| A new self-hosted runner | org.register_self_hosted_runner |
| The configuration of a runner group | org.runner_group_updated |
Two limits, read in the GitHub documentation, decide how to use it. Only the organisation's owners can access it, and the interface only goes back 180 days. API access, which makes it possible to export it to a SIEM, is reserved for GitHub Enterprise Cloud: without it, keeping more than six months requires a regular manual export. On the GitLab side, audit events play the same role, as the lesson GitLab CI: audit, compliance and traceability (in French) details.
Where does your pipeline stand? A four-level maturity model
This model helps place a pipeline and choose the next step, not do everything at once. Each level assumes the previous one: signing artifacts built on a persistent runner with broad permissions protects little. Start with basic hygiene, which costs little and closes the most frequent attacks.
| Level | Goal | Controls |
|---|---|---|
| 1. Basic hygiene | Close the most frequent attacks | Minimal permissions per job, lock files with npm ci, no hard-coded secret |
| 2. Pinning and isolation | Run nothing that can change on its own | Actions and dependencies pinned by SHA, allowlist of actions, ephemeral runners, one secret per environment |
| 3. Verification and traceability | Prove what is shipped | Signed artifacts, SBOM on every build, provenance attestations, audit log read and kept |
| 4. Zero trust | Stop storing long-lived credentials | OIDC wherever possible, mandatory verification before deployment, runner monitoring |
Key points
- A compromised pipeline gives access to secrets, code, artifacts and production, on every run.
- The four control surfaces are identities, dependencies, runners and artifacts.
- Least privilege: permissions emptied at workflow level, then granted job by job.
- Pin by commit SHA: the tj-actions attack of March 2025 moved tags, not SHAs.
- Ephemeral runners and network isolation remove persistence from one job to the next.
- Signature, provenance and SBOM say who produced the artifact, how, and what it contains.
- Favour structural controls, which make the attack impossible, over point-in-time controls, which only observe it.
- An organisation allowlist decides which third-party code runs; the audit log says who changed the settings that Git does not see.
Knowledge check
Check that the essentials of this lesson are in place. The questions only cover what has just been explained here.
Knowledge check
Check what you have learned with this interactive quiz
How it works
- The timer starts when you click Start
- Multiple choice, true/false and short answer questions
- You can move between questions
- Detailed results are shown at the end
Starts the quiz and the timer
Review
(0/0)Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.
Weak areas
No topic below the pass mark.
Recommended next
Hints before you try again?
Continue the course: Securing secrets in your CI/CD pipelines
Continue the course: CI/CD anti-patterns: recognising and fixing pipeline mistakes
A new full quiz with random questions
Work again only on the questions you missed