StackPractices
intermediate By Mathias Paulenko

CI/CD Pipeline Security: Prevent Supply Chain Attacks

A practical guide to securing CI/CD pipelines: secrets management, least-privilege runners, artifact signing, and supply chain defense.

Overview

CI/CD pipelines are high-value attack targets. They hold source code, secrets, production access, and signing keys — everything an attacker needs to reach your users without touching your app directly. We’ve watched this stop being theoretical: SolarWinds’ build system was poisoned in 2020 and shipped backdoored updates to 18,000 organizations; in 2021 attackers modified Codecov’s bash uploader inside its CI process and exfiltrated environment variables from thousands of pipelines; in early 2025 a compromised tj-actions/changed-files version leaked secrets in GitHub Actions logs across more than twenty thousand repositories.

The pattern behind all three is the same: the pipeline was trusted more than anything it produced — every one of those attacks reached production because nobody verified the middle. This guide works through the controls that break that assumption, layer by layer: what to lock down, in what order, and how to prove it’s working.

Before the controls, a quick threat model. Your pipeline has four things an attacker wants: secrets, the ability to modify what gets built, network access to move laterally, and signing identity to make malicious artifacts look legitimate. Every layer below exists to deny one or more of those four prizes — when a control doesn’t map to one, it’s probably ceremony, not security.

When to Use

  • You manage CI/CD pipelines that deploy to production
  • You want to shrink the blast radius of a compromised build system
  • You need to meet compliance frameworks (SOC 2, ISO 27001, FedRAMP)
  • You’ve suffered, or want to prevent, a supply chain attack
  • You’re moving from self-hosted runners to cloud-native CI/CD and back again

For a checklist-oriented version of these controls, the CI/CD pipeline security template maps each one to a verification method. For alternatives, see the Complete Guide to Secrets Management.

Core Concepts

ConceptDescription
Supply chain attackInjecting malicious code via compromised dependencies, actions, or build tools
Least-privilege runnerBuild agents with minimal access to secrets and infrastructure
Artifact signingCryptographically proving a built artifact came from your pipeline
Dependency scanningDetecting known vulnerabilities in libraries automatically
Pipeline as codeVersion-controlled CI/CD definitions that enforce security policy
SBOMA bill of materials listing every component in a release
OIDC federationShort-lived identity tokens instead of stored cloud credentials
SLSAA framework grading supply chain integrity from SLSA 0 to SLSA 4

Two concepts deserve a second look. SLSA levels give you a vocabulary for pipeline maturity: SLSA 1 means a documented build process, SLSA 2 means a hosted build service, SLSA 3 means hardened builders, SLSA 4 means two-person review plus reproducibility. Most teams sit at 1-2; signing plus provenance gets you toward 3. OIDC federation is the control with the best effort-to-impact ratio in this guide — it swaps long-lived cloud secrets for tokens that expire in minutes and can’t leak from a fork.

Step-by-Step CI/CD Security Hardening

Every stage below maps to a real incident pattern. Work top to bottom; each layer assumes the previous one holds.

flowchart diagram: Source code

1. Secure Pipeline Configuration

Treat pipeline definitions as production code — review them, version them, and assume they will be modified by someone you didn’t intend.

# Example: GitHub Actions security hardening
name: Secure Build
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

# Minimal permissions by default
permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      # Pin actions to specific commit hashes
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2

      # Use OIDC for cloud authentication instead of long-lived secrets
      - name: Authenticate to AWS
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/CICD-Build-Role
          aws-region: us-east-1

      # Verify dependencies before installing
      - name: Install dependencies
        run: |
          npm ci --ignore-scripts  # Skip post-install scripts
          npm audit --audit-level=moderate

Security checklist for pipeline definitions:

  • Pin all third-party actions to commit SHA, not version tags — tags move, SHAs don’t. The tj-actions incident happened because a tag was repointed at a malicious commit.
  • Use permissions: blocks with minimal scopes; an explicit contents: read at workflow level is the cheapest win in this entire guide.
  • Never run CI on pull_request_target with checkout of untrusted code — it executes PR code with write tokens and secrets.
  • Pass --ignore-scripts (or equivalent) when installing dependencies; post-install hooks run arbitrary code with your pipeline’s privileges.
  • Enforce branch protection with required reviews so the pipeline definition itself can’t be bypassed.

2. Secrets Management

Secrets in CI/CD are the most common attack vector because they’re the easiest prize: one leaked AWS_SECRET_ACCESS_KEY in a log or a forked-repo build and the attacker is inside.

# BAD: Hardcoded secrets in pipeline files
# AWS_SECRET_ACCESS_KEY=AKIA...  # NEVER DO THIS

# GOOD: Native secret stores
# GitHub Actions: repository/environment secrets
# GitLab CI: masked, protected CI/CD variables
# Azure DevOps: secret variable groups

# BETTER: OIDC eliminates stored cloud credentials entirely
# AWS: configure-aws-credentials with role-to-assume
# GCP: Workload Identity Federation
# Azure: managed identity + federated credentials

Secrets: what works

  • Rotate whatever remains stored every 30-90 days — and after any incident, rotate everything at once, not one at a time.
  • Prefer short-lived tokens (1-hour TTL); an expired token can’t be replayed.
  • Scope secrets to jobs and environments, never pipeline-wide.
  • Audit secret access logs; most platforms expose who used what and when.
  • Never echo secrets. Most CI systems mask known secrets, but derived values — tokens minted from secrets — often aren’t masked.

3. Runner Hardening

Your build agents are as critical as production servers — often more so, because they hold credentials production never sees.

StrategyDescriptionImplementation
Ephemeral runnersFresh VM or pod for every buildGitHub-hosted, GitLab SaaS, K8s jobs
Network isolationRestrict runner egressVPC, private subnets, egress proxy
Least privilegeMinimal IAM per job typeSeparate roles per pipeline/project
Immutable imagesPre-hardened, scanned runner imagesPacker, golden AMI, image signing
No secrets on diskMemory-only credentialstmpfs mounts, runtime injection
# Self-hosted runner hardening checklist
# 1. Isolate in a VPC with no direct internet egress
# 2. Separate runner pools per team or trust level
# 3. No sudo/root for the runner user
# 4. Mount /tmp as noexec, nodev, nosuid
# 5. Run container builds rootless (podman/docker rootless mode)
# 6. Wipe the workspace between jobs — never reuse build dirs
# 7. Scan runner images weekly for CVEs and rebuild often

A practical note: self-hosted runners behind your firewall frequently have more network access than production services. If you can’t isolate them properly, cloud-hosted ephemeral runners are the safer default.

4. Dependency and Artifact Security

Your dependencies are your weakest link — you inherit every vulnerability and every compromised maintainer account in your dependency tree.

# Example: GitHub Actions dependency scanning pipeline
jobs:
  security-scan:
    runs-on: ubuntu-latest
    permissions:
      security-events: write
    steps:
      - uses: actions/checkout@v4

      # SAST (Static Application Security Testing)
      - name: Run CodeQL
        uses: github/codeql-action/init@v3
        with:
          languages: javascript, python
      - uses: github/codeql-action/analyze@v3

      # Dependency vulnerability scanning
      # Dependabot alerts + updates in repo settings,
      # or Snyk / OWASP Dependency-Check

      # Container image scanning
      - name: Build image
        run: docker build -t myapp:${{ github.sha }} .
      - name: Scan image with Trivy
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: 'myapp:${{ github.sha }}'
          severity: 'CRITICAL,HIGH'
          exit-code: '1'

Artifact security practices:

  • Sign every artifact — cosign keyless signing via OIDC means no private key to manage or lose.
  • Generate and publish SBOMs for every release; you can’t respond to the next Log4Shell without an inventory.
  • Scan images before they reach the registry and block on HIGH/CRITICAL.
  • Verify signatures at deploy time — signing without verification is decoration.
  • Use immutable registries with retention policies so yesterday’s artifact can’t be silently replaced.
  • Defend against dependency confusion: private registries with namespace reservation, lockfiles with integrity hashes, and signature verification for internal packages.

5. Deployment Security

The last stage must be as hardened as the first — a compromised deploy step defeats everything upstream.

# Example: Secure deployment script
#!/bin/bash
set -euo pipefail

# 1. Verify artifact signature
cosign verify --key cosign.pub artifact.sig myapp:${DEPLOY_VERSION}

# 2. Confirm SBOM matches expected components
sbom-diff expected.sbom generated.sbom

# 3. Smoke tests before any traffic shift
./smoke-tests.sh --target staging

# 4. Deploy with automatic rollback on failure
./deploy.sh --version ${DEPLOY_VERSION} --rollback-on-failure

# 5. Verify deployment health
./health-check.sh --target production

Deployment security rules:

  • Require manual approval for production — environments with required reviewers give you an audit trail for free.
  • Auto-rollback on health-check failure; blue-green or canary limits blast radius when a bad artifact slips through.
  • Separate staging and production credentials — the deploy role for staging should be useless in production.
  • Log every deployment event to an append-only audit trail outside the CI platform itself.

Companion files: the ci-cd-security-guide companion repo has a hardened GitHub Actions workflow implementing these steps (secure-workflow.yml) plus the self-hosted runner hardening checklist.

What Works

  • Assume the pipeline will be compromised. Design for containment — blast radius, detection, recovery — not just prevention.
  • Ephemeral infrastructure beats hardened infrastructure. A fresh runner can’t harbor yesterday’s malware.
  • Verify everything downstream: signatures, SBOMs, checksums. Trust is what attackers exploit.
  • Minimum permissions per job. If a job only reads, don’t let it write; if it doesn’t need secrets, don’t grant them.
  • Watch pipeline behavior, not just results: alert on unexpected egress, credential use outside deploy jobs, or builds that suddenly take 3x longer.
  • Practice incident response: when a pipeline is compromised, can you rotate every secret it touched within an hour? If you don’t know, drill it.

Common Mistakes

  • Long-lived secrets in CI: OIDC or short-lived tokens wherever the platform supports it.
  • Unhardened self-hosted runners: they usually hold broader network access than production and persist between jobs.
  • Trusting pull_request_target: it runs untrusted code with write tokens — a documented, repeatedly-exploited pattern.
  • Skipping dependency scanning: known CVEs in dependencies remain the most exploited vector in the chain.
  • Trusting version tags on actions: tags get repointed; pin SHAs.
  • Ignoring base-image CVEs: start from minimal, maintained base images (distroless, Alpine where appropriate) and rebuild regularly.
  • Masking only the raw secret: derived tokens and base64-encoded variants leak through logs unmasked.

Variants

  • GitHub Actions: permissions scoping, pull_request vs pull_request_target semantics, OIDC via id-token: write, Dependabot, environments with required reviewers.
  • GitLab CI: masked/protected variables, id_token OIDC per job, runner tags for isolation, built-in container and secret scanning. The OIDC equivalent looks like this:
# GitLab: OIDC token per job, aud claim scoped to the job
deploy:
  id_tokens:
    AWS_ID_TOKEN:
      aud: sts.amazonaws.com
  script:
    - aws sts assume-role-with-web-identity
        --role-arn arn:aws:iam::123456789012:role/gitlab-deploy
        --role-session-name gitlab-$CI_JOB_ID
        --web-identity-token $AWS_ID_TOKEN
        --duration-seconds 3600

The token lives only inside the job, is bound to the project and ref, and dies with it — same trade as GitHub’s id-token: write. Pair it with protected variables so secrets only reach jobs on protected branches.

  • Jenkins: agent isolation (one job per agent or K8s agents), credential scopes, pipeline shared libraries as a review boundary, script security.
  • Cloud-native builds: AWS CodeBuild, Google Cloud Build, Azure DevOps give you IAM-integrated identities and isolated builders — less runner hardening for you, but you still own permissions and artifact verification.

Responding to a Pipeline Compromise

Every control above assumes the day it fails. When — not if — a pipeline is compromised, the response needs to be faster than the attacker’s next move. This is the playbook.

1. Isolate first. Disable the affected workflow or runner pool immediately; don’t investigate a live target. Pause deploys. If the runner was self-hosted, kill the VM or pod rather than cleaning it — ephemeral beats forensics on a compromised box.

2. Assume every secret it touched is gone. Rotate all of them, in dependency order: cloud credentials first (they unlock everything else), then registry and deploy keys, then third-party tokens, then signing keys. With OIDC this step shrinks to revoking trust policies — one more reason to migrate.

3. Audit what the pipeline produced. Check which artifacts were built and deployed during the exposure window: image digests, provenance attestations, deploy logs. Any artifact you can’t prove clean gets redeployed from a verified build.

4. Audit what it touched. Look for unexpected egress, new webhooks, modified workflow files, suspicious package installs, and access to resources the job shouldn’t have needed. The pipeline’s own logs are the first thing an attacker scrubs — ship them to an external, append-only store before an incident, not after.

5. Rebuild, don’t patch. Recreate runners, re-issue tokens, re-deploy from known-good artifacts. A patched compromised system is still a compromised system.

Compliance mapping

If you’re doing this for a framework, here’s where the controls land:

Framework requirementPipeline control that satisfies it
SOC 2 CC6.1 (logical access)OIDC, scoped secrets, minimal permissions
SOC 2 CC7.1 (monitoring)Pipeline egress alerts, audit log shipping
ISO 27001 A.8.9 (config mgmt)Pipeline as code, branch protection, review
ISO 27001 A.8.25 (secure dev)SAST/SCA gates, Trivy scanning, code review
NIST SSDF PO.5 / PW.6Artifact signing, SBOM, provenance
FedRAMP CM-7 / SI-7Least privilege runners, signature verification at deploy

None of this is ceremonial. Auditors ask for evidence — and the approval logs, signed artifacts, and SBOMs these controls produce are exactly the evidence they’re looking for.

Threat-to-Control Quick Reference

When a control doesn’t map to a threat on this table, question whether it’s earning its keep.

AttackEntry pointPrimary control
Malicious PRpull_request_target + secretspull_request only, no secrets on forks
Poisoned actionRepointed version tagSHA pinning + review
Secret exfiltrationLog output, forked buildsOIDC, masking, job-scoped secrets
Lateral movementSelf-hosted runner accessEphemeral, isolated runners
Dependency confusionPublic package same-namePrivate registry + namespace
Tampered artifactRegistry write accesscosign sign + verify at deploy
Unauthorized deployStolen deploy credentialsOIDC + manual approval + audit

Keep Improving

CI/CD security is a continuous practice, not a hardening project that ends. Every new action, dependency, and runner type adds surface — and every convenience shortcut you take in the pipeline is one an attacker can take too. Pick the control your next audit would flag first — for most teams that’s OIDC or artifact signing — and put it in this sprint, not the backlog. Then schedule the next review; a pipeline you hardened six months ago has drifted since, and drift is where incidents live.

Further Reading

Frequently Asked Questions

How do I migrate from long-lived secrets to OIDC?

Configure workload identity federation in your cloud provider (AWS IAM OIDC provider, GCP Workload Identity, Azure federated credentials), then swap static credentials for role-to-assume in your auth step. The OIDC token is scoped to the job's repository and branch, so a leaked token is nearly worthless.

Should I use self-hosted or cloud-hosted runners?

Cloud-hosted runners are ephemeral and isolated by default — the safer baseline. Self-hosted runners buy you caching speed and network proximity but hand you runner hardening, persistence risk, and egress control. Choose them only when you can isolate them properly.

How do I prevent dependency confusion attacks?

Reserve your internal namespace on a private registry, pin exact versions with lockfiles containing integrity hashes, and prefer private-over-public resolution order so internal-lib never resolves to a public package of the same name.

What is the minimum viable CI/CD security setup?

Branch protection, Dependabot or equivalent, OIDC for cloud auth, SHA-pinned actions, and image scanning before deploy. That set blocks most documented incidents and takes days, not months.

How does artifact signing actually stop an attacker?

It changes the trust question at deploy time from "did this image come through our pipeline?" to "can this image prove it came through our pipeline?" An attacker with registry access can swap an image — they can't forge a signature without your keys or identity.

How do I measure whether pipeline security is improving?

Track secrets eliminated by OIDC, percentage of signed artifacts, mean time to patch critical dependency CVEs, and SLSA level progress. If those numbers move, the posture is real — not just policy documents.

What's the first thing to do after discovering a compromised pipeline?

Isolate it before investigating: disable the workflow, kill the runner, pause deploys. Then rotate every secret the pipeline could access, in dependency order — cloud credentials first, since they unlock everything else. Auditing can wait an hour; an open pipeline cannot.

Do I need all these controls on day one?

No. Start with the minimum viable set — branch protection, dependency scanning, OIDC, SHA pinning, image scanning — and layer the rest in order of what a breach would actually cost you. Signing and provenance matter most once external consumers depend on your artifacts, not before.