How to Secure Your CI/CD Pipeline (DevSecOps)

Your CI/CD pipeline is the most privileged thing in your engineering org. It holds credentials to production, runs code from every branch and pull request, and signs off on the artifacts your users trust. Compromise the pipeline and an attacker skips every other control. So securing CI/CD is two jobs at once: locking down the pipeline itself, and vouching for what it ships. This guide covers both, ordered by impact.

Start here #

Three changes stop the majority of real-world pipeline attacks, and none of them take a week:

Kill long-lived cloud keys. Replace static AWS_ACCESS_KEY_ID secrets with OIDC federation so jobs assume a role at runtime.

Add SCA and image scan gates that fail the build. Most breaches ride in through a known-vulnerable dependency or base image, not novel exploits.

Pin every third-party action to a commit SHA. A tag like @v4 is mutable; a SHA is not.

Everything after this is worth doing, but these three move the needle first.

Secrets: stop shipping credentials into the pipeline #

The worst secret is the one that never expires. A leaked long-lived cloud key works until someone notices, usually after the invoice or the incident.

Use OpenID Connect (OIDC) so your runner exchanges a short-lived signed token for a scoped cloud role. No static keys live in CI at all. Here is the GitHub Actions to AWS pattern:

yaml.yaml

permissions:
id-token: write # required to request the OIDC token
contents: read

jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
with:
role-to-assume: arn:aws:iam::123456789012:role/ci-deploy
aws-region: us-east-1
role-session-name: gha-${{ github.run_id }}

On the AWS side, the trust policy restricts which repo and branch can assume that role, so a token minted for another repo is useless. The credentials it hands back last minutes, not months.

For the secrets you still genuinely need (a third-party API token, a signing key), keep them scoped and short-lived, pull them from a vault at job time, and never echo them into logs. Then add secret scanning (Gitleaks, TruffleHog, or your platform's native scanner) on every push and pull request. Treat any hit as a rotation event, not a code-review comment. The secret is already in git history.

Least privilege for the runner and its jobs #

Default pipeline permissions are almost always too broad. Set the top-level token to read-only and grant write only on the specific jobs that need it. On GitHub that means permissions: contents: read at the workflow level, elevated per job. Give each job the narrowest cloud role it can do its work with, and separate build credentials from deploy credentials so a compromised test step cannot push to production.

Pull requests from forks are the classic poisoning vector. A fork can propose a workflow change or a malicious build script that runs with your secrets if you let it. Use pull_request (not pull_request_target) for untrusted contributions so the run executes without access to secrets, require manual approval for first-time contributors, and never let fork code reach a job that holds deploy credentials.

Pin third-party actions to a full commit SHA, not a tag or branch. Tags are mutable, and a maintainer compromise or a malicious retag turns @v3 into arbitrary code inside your privileged runner. Use Dependabot or Renovate to keep those SHAs current so pinning does not rot into stale, vulnerable versions.

Security gates in the pipeline #

Gates are where DevSecOps stops being a slogan. Each runs in CI and fails the build on findings above your threshold. Wire them so high and critical severities break the pipeline; leave medium and low as reported-but-passing so you do not train everyone to ignore red.

SAST: Semgrep or CodeQL scans your own source for injection, auth, and unsafe-API bugs.

SCA / dependency scanning: the highest-yield gate, since most exploited vulnerabilities live in your dependencies, not your code. See our dependency and SCA scanning guide for how to tune it.

Container image scanning: scan the built image and its base layers before you push. Details in container vulnerability scanning.

DAST: exercise the running app (OWASP ZAP against a staging deploy) to catch what static analysis misses.

IaC scanning: tfsec or Checkov on your Terraform and Kubernetes manifests to catch open security groups, public buckets, and missing encryption before they ship.

A scan-and-fail gate looks like this:

yaml.yaml

- name: Scan image, fail on high/critical
uses: aquasecurity/trivy-action@6e7b7d1fd3e4fef0c5fa8cce1229c54b2c9bd0d8 # v0.24.0
with:
image-ref: ${{ env.IMAGE }}:${{ github.sha }}
severity: HIGH,CRITICAL
exit-code: '1' # non-zero exit stops the pipeline
ignore-unfixed: true # don't block on CVEs with no fix yet

The exit-code: '1' is what turns a report into a gate. Without it, the scan is decoration.

Supply-chain integrity: prove what you shipped #

Passing gates tells you the artifact was clean when it was built. Supply-chain integrity lets you prove, at deploy time, that the thing you are about to run is exactly that artifact.

SBOM: generate a software bill of materials (Syft, or native build-tool support) for every build. When the next big CVE lands, you answer "are we affected" in minutes instead of days. See supply-chain security with SBOMs.

Artifact signing: sign images and artifacts with Sigstore's cosign, ideally keyless via OIDC so there is no signing key to steal.

Provenance and SLSA: emit signed provenance recording how and where the artifact was built, moving you up the SLSA levels.

Verify before deploy: the step teams skip. Have your deploy step, and ideally a cluster admission controller, verify the signature and provenance and refuse anything unsigned. Signing without verification protects no one.

Protect the artifact, the registry, and the repo #

Lock down where artifacts live. Require authentication and least-privilege access on your registry, enable immutable tags so a pushed digest cannot be silently replaced, and restrict who can publish.

On the source side, turn on branch protection for your main branches: required status checks (your gates above), required reviews, and no direct or force-pushes. Require signed commits where you can. Finally, keep audit logs for the pipeline, the registry, and cloud role assumptions, and ship them somewhere an attacker who owns the pipeline cannot edit. When something goes wrong, that trail is how you scope the blast radius.

For the application code flowing through all of this, our application security best practices guide covers the vulnerability classes your SAST and DAST gates are looking for.

The call we'd make #

If you are starting from a typical pipeline with static keys and no gates, spend your first week here: move to OIDC, add SCA and image scanning as build-breaking gates, and pin your actions to SHAs. That combination removes the credentials worth stealing, blocks the vulnerabilities most likely to be exploited, and closes the action-poisoning path.

Then invest in supply-chain integrity: SBOMs, cosign signing, and verify-before-deploy. It pays off later, during the incident where you need to prove exactly what ran in production. A pipeline that mints short-lived credentials, fails loudly on known-vulnerable dependencies, and refuses to deploy anything it cannot verify is a genuinely hard target.