How to Secure a CI/CD Pipeline: 9 Controls That Block Supply Chain Attacks
Varun Kumar 9 min read 20 May 2026
Securing a CI/CD pipeline means understanding what attackers actually target. Your pipeline has network access, cloud credentials, and the ability to deploy to production. That makes it one of the highest-value targets in your entire environment. Most teams bolt on a secrets scanner at the end and call it done. That's not security. That's checkbox theatre.
The real work is: eliminating static credentials, locking down runner permissions, validating every third-party action you execute, and signing artifacts so you know what you're deploying. SolarWinds attackers compromised a build system and shipped malware to 18,000 customers. Codecov's bash uploader was tampered with and leaked CI secrets from hundreds of companies. These aren't theoretical. They happened because pipelines had no integrity controls.
Here's what you actually need to do.
What Attackers Actually Target First
Before you build controls, know what attackers look for the moment they get a foothold in or adjacent to a pipeline.
The first thing they look for is credentials in runner environment variables. GitHub Actions exposes all secrets as environment variables in the shell process running your job. If an attacker can inject code that executes in your runner context, they print the environment and grab every credential in it. The mitigation isn't just limiting secrets. It's limiting what code can execute in your pipeline to code you've reviewed. SHA-pinned actions, reviewed dependencies, no curl | bash install patterns.
The second target is the pipeline's cloud identity. If your runner has an IAM role attached (EC2 instance profile, Workload Identity on GKE, managed identity on Azure), an attacker with code execution on the runner hits the cloud metadata endpoint at 169.254.169.254 and gets those credentials. They don't need your secrets. They need the network-accessible credential your runner inherits from its host. Scope that IAM role to exactly what the pipeline needs. Nothing broader.
The third target is your artifact registry write access. If an attacker gets credentials that can push to your container registry, they swap images. Your CD pipeline pulls and deploys the attacker's image without knowing the build process was never involved. Separate build credentials from deploy credentials. The job that pushes images should not be the same identity that reads them in production.
The fourth target is branch protection. If your main branch isn't protected, an attacker with repo write access can push directly to main, trigger your pipeline, and get code deployed to production with no review. Branch protection rules and required reviewers are pipeline security controls, not just code quality controls.
The Real Attack Surface
Most teams think about secrets in environment variables. That's one vector. The full picture is worse.
Compromised third-party actions are the supply chain risk most teams ignore. When you write uses: some-org/some-action@v2 in GitHub Actions, you're executing arbitrary code with your pipeline's credentials. v2 is a mutable tag. It can point to different code tomorrow than it does today. The tj-actions/changed-files incident in 2025 proved this: a single compromised action printed CI secrets from thousands of repos.
Overprivileged runners are everywhere. A runner that can push to any branch and assume any IAM role is a lateral movement nightmare if an attacker gets code execution. I've seen runners with AdministratorAccess in AWS "just to make things easier." That's a breach waiting to happen.
Artifact integrity gaps mean you can't verify that what you built is what you're deploying. Without signing, an attacker with write access to your artifact registry can swap binaries.
Pipeline-as-code injection is underrated. If your pipeline reads input from PR titles or commit messages and passes them to shell commands, that's a command injection vector.
Certified DevSecOps Professional
Build secure CI/CD pipelines with SCA, SAST & DAST in 100+ labs.
VIEW COURSE
Secrets Management in Pipelines
Static long-lived credentials are the root of most pipeline security incidents. The fix is OIDC token exchange.
OIDC (OpenID Connect) is an authentication protocol that lets your CI provider prove its identity to a cloud provider, so the pipeline gets short-lived credentials instead of storing a permanent secret.
With GitHub Actions and AWS, you configure an IAM OIDC identity provider and a role with a trust policy scoped to your repo. The pipeline gets a token that expires when the job ends. No AWS_ACCESS_KEY_ID stored anywhere.
That replaces a stored IAM key. Use the same pattern for GCP Workload Identity Federation and Azure federated credentials.
For secrets you can't replace with OIDC (third-party API keys, signing certs), use HashiCorp Vault with the JWT auth method, or your cloud provider's native secret manager. Vault lets you define short-lived dynamic credentials for databases and cloud resources. A pipeline grabs a Postgres password that expires in 15 minutes. If it leaks, it's already dead.
Never store secrets in environment variables at the repo or org level if you can avoid it. GitHub Actions secrets are better than plaintext env vars, but they still show up in debug logs if someone sets ACTIONS_STEP_DEBUG=true. Use SOPS with KMS encryption for secrets in your repo configs. The private key never leaves KMS.
For secret detection, detect-secrets in pre-commit and gitleaks as a CI step catch most cases before they land in the main branch.
Pipeline Permissions and Least Privilege
The GITHUB_TOKEN in GitHub Actions has a default permission set that's too broad for most jobs. Set it to read-only at the workflow level and grant specific permissions per job.
This is more granular than what most teams ship. The security-events: write permission is what lets tools like Semgrep and CodeQL upload SARIF results to GitHub's code scanning dashboard. Without it, you get scan output in the CI log but not integrated into PR comments and the security tab. The id-token: write in the deploy job is what enables keyless OIDC authentication with AWS, GCP, or Azure. Grant it only in jobs that actually need cloud access.
One more scope worth knowing: pull-requests: write lets a job comment on PRs. Only the job that posts scan results needs this. Your build and test jobs don't. Granting it at the workflow level means every job can comment, which is more exposure than you want.
For self-hosted runners, network segmentation is your next layer of defense. Runners that deploy to production should live in a separate network segment with outbound access scoped to your artifact registry, your Kubernetes API, and nothing else. An attacker who compromises a runner with unrestricted egress can exfiltrate secrets or reach internal services that your pipeline was never meant to reach.
For runners, don't use hosted runners for anything touching production credentials if you can self-host with tighter network controls. If you use GitHub-hosted runners, scope IAM roles narrowly. One role per pipeline, scoped to the minimum S3 bucket or ECS cluster that job touches.
Runner isolation matters too. In Jenkins, don't share agents across projects with different trust levels. An agent running a public contributor's PR should not share an executor with a job that has production database access.
Dependency and Supply Chain Controls
Pin actions by commit SHA, not by tag. Tags are mutable. SHAs are not.
Yes, this is annoying to maintain. Use Dependabot to open PRs when action versions update. Review the diff. Merge the SHA bump. This is the minimum viable supply chain control.
For your own dependencies, generate an SBOM (Software Bill of Materials) on every build. Syft works well here: syft . -o spdx-json > sbom.spdx.json. Feed it to Grype for vulnerability matching. This gives you an inventory of what's in your artifact and flags known CVEs before you ship.
Artifact Integrity with Cosign and SLSA
Cosign is a tool from the Sigstore project that signs container images and other artifacts using ephemeral keys tied to your CI identity via OIDC.
Here's the complete signing workflow in a GitHub Actions job:
Signing by digest rather than by tag is intentional. If you sign the tag and the tag gets reassigned, the signature no longer matches what you think you signed. Signing the digest guarantees the signature is tied to the exact bytes you built.
The verification step belongs in your deployment pipeline, before the image is pulled:
In Kubernetes, enforce this at admission with a policy controller. Kyverno and OPA Gatekeeper both support Cosign verification policies. A Kyverno ClusterPolicy can block any pod from starting if its image doesn't have a valid Cosign signature from your pipeline identity. That's the real enforcement point: not "we sign images" but "we can't run unsigned images."
Keyless signing means you don't manage a signing key. The signature is tied to the GitHub Actions OIDC token for that build. Verification confirms the image was built by your pipeline, not modified after the fact.
SLSA (Supply chain Levels for Software Artifacts) is a framework with four levels. Most teams are at level 0. Getting to SLSA 2 requires a hosted build service that generates signed provenance. GitHub's slsa-framework/slsa-github-generator handles this without much setup.
Security Gates in the Pipeline
Most tutorials stop here, which is exactly where things go wrong. You need to know where in the pipeline each tool fires.
On pull request (PR open/update):
- Semgrep for SAST (language-aware, low false-positive rate when tuned)
- detect-secrets or gitleaks for secrets scanning
- Trivy for container image CVE scanning
- Checkov for IaC misconfigs
On merge to main:
- Full SAST scan (Semgrep or SonarQube)
- SCA with SBOM generation
- Artifact signing with Cosign
- SLSA provenance generation
Pre-deploy to staging/production:
- Cosign verification (don't deploy unsigned images)
- Policy check: does this image have a clean Trivy scan? Use OPA/Rego or Kyverno for this in Kubernetes.
In GitLab CI, use rules and needs to wire this into merge request pipelines. In Jenkins, use shared libraries so security gates are enforced consistently across teams.
The goal isn't to slow down deployments. It's to make failures loud and early. A SAST finding on a PR is a 5-minute fix. The same finding in production is an incident.
If you want to go deeper on building and auditing these controls in a structured way, the Certified DevSecOps Professional program covers exactly this, with hands-on lab environments.
FAQ
Varun is a Security Research Writer specializing in DevSecOps, AI Security, and cloud-native security. He takes complex security topics and makes them straightforward. His articles provide security professionals with practical, research-backed insights they can actually use.
