On this page
Your CI/CD pipeline has more access than almost anyone in your company. It can read the source code, push container images, change infrastructure and deploy to production, often without a human watching. That makes it one of the most attractive targets an attacker can find, and the incidents of the last few years bear that out: compromised build systems, malicious dependencies, and in March 2025 a popular GitHub Action (tj-actions/changed-files) that was altered to dump CI secrets into public build logs across thousands of repositories.
This article is the hardening baseline we apply when we take over or build a pipeline for a client on GitHub Actions and AWS. It covers replacing long-lived cloud keys with OIDC, scoping permissions tightly, pinning third-party actions, keeping secrets out of reach of untrusted code, and producing signed artifacts with SBOMs that deployment can verify. The examples are GitHub Actions and AWS, but the principles carry over to GitLab CI, Azure and Google Cloud.
The threat model in one table
It helps to be explicit about what we are defending against, because each control addresses a different path.
| Threat | Example | Primary control |
|---|---|---|
| Leaked long-lived credentials | AWS access key in a repo secret is exfiltrated | OIDC federation, no static keys |
| Over-privileged pipeline | A test job can deploy to production | Least-privilege tokens and roles, environments |
| Compromised third-party action | A tag is moved to malicious code | Pin actions to full commit SHAs |
| Untrusted code with secrets | A fork pull request reads deploy secrets | Safe triggers, secret scoping |
| Tampered artifacts | An image is replaced in the registry | Signing and verification at deploy |
| Unknown dependencies | A vulnerable library ships unnoticed | SBOMs and scanning |
Replace static cloud keys with OIDC
The single biggest improvement for most pipelines is removing long-lived AWS access keys from CI secrets. With OpenID Connect, each workflow run requests a short-lived signed token from GitHub, and AWS exchanges it for temporary credentials for a specific IAM role. There is nothing permanent to steal, and credentials expire within an hour by default.
First, create the OIDC identity provider in the AWS account (once per account), with the URL https://token.actions.githubusercontent.com and audience sts.amazonaws.com. We manage this in Terraform alongside the roles.
The trust policy is where the security lives. It must restrict which repository, and ideally which branch or environment, can assume the role.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:example-org/api:environment:production"
}
}
}
]
}The most dangerous mistake we see is a sub condition with a broad wildcard such as repo:example-org/*, or no sub condition at all. That allows any repository in the organisation (or worse, on GitHub) to assume the role. Scope sub to one repository and, for deployment roles, to a protected environment or the main branch.
The workflow then requests the token and assumes the role:
name: deploy
on:
push:
branches: [main]
permissions:
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
id-token: write # required to request the OIDC token
steps:
- uses: actions/checkout@<full-commit-sha> # v5
- uses: aws-actions/configure-aws-credentials@<full-commit-sha> # v5
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-api-deploy-prod
aws-region: eu-west-1
- run: ./scripts/deploy.shReplace <full-commit-sha> with the actual 40-character commit hash for the release you have reviewed; we cover why in the section on pinning.
Apply least privilege at every layer
Least privilege in a pipeline has three layers, and all three matter.
The GITHUB_TOKEN
Every workflow run gets a GITHUB_TOKEN. Set the organisation or repository default to read-only, then grant extra permissions per job, only where needed. The permissions block at the top of the workflow above sets a read-only baseline; the deploy job adds only id-token: write.
IAM roles
Create separate roles per purpose and environment: one for running Terraform plans (read-only), one for applying to staging, one for deploying to production. Each role's permissions policy allows only the actions that job performs, for example pushing to one ECR repository and updating one ECS service, not AdministratorAccess. IAM Access Analyzer can generate a policy from CloudTrail activity, which is a practical way to tighten a role that started too broad.
Environments
GitHub environments let you require manual approval, restrict which branches can deploy and scope secrets so only jobs targeting that environment can read them. Combined with a trust policy that only accepts environment:production, this means a workflow on a feature branch cannot obtain production credentials even if someone edits the YAML.
Pin third-party actions to commit SHAs
A reference like uses: some-org/some-action@v3 points to a Git tag, and tags can be moved. In the tj-actions/changed-files incident, attackers updated existing version tags to point at a malicious commit, so every workflow referencing those tags ran the attacker's code on its next run.
Pinning to a full commit SHA makes the reference immutable:
- uses: actions/setup-node@<full-commit-sha> # v4.4.0The comment keeps the version readable, and Dependabot and Renovate both understand this format and will open pull requests to bump the SHA and the comment together. Tools such as pinact can convert an existing repository in one pass. GitHub also lets organisation administrators enforce SHA pinning through the allowed-actions policy, which we switch on once a repository is converted.
Pinning is not a substitute for judgement. Prefer actions from GitHub, the cloud providers and well-maintained projects; for small actions that do something trivial, a few lines of shell in a run step are often safer than another dependency.
Keep secrets away from untrusted code
Secrets hygiene in CI is mostly about which code can run with access to secrets.
- Be careful with
pull_request_target. It runs in the context of the base repository, with secrets and a write-capable token. If such a workflow checks out and runs code from the pull request, a fork can steal your secrets. Use the plainpull_requesttrigger for building and testing contributions; it gives forks no secrets. - Never interpolate untrusted input directly into scripts. An expression like
${{ github.event.pull_request.title }}inside arunblock is a script injection risk. Pass it through an environment variable instead and reference it as a shell variable. - Prefer OIDC and short-lived tokens over stored secrets for every service that supports it: cloud providers, package registries such as npm and PyPI with trusted publishing, and HashiCorp Vault.
- Do not print secrets. GitHub masks registered secrets in logs, but not values derived from them, such as base64-encoded or JSON-wrapped copies. Register derived values with
::add-mask::. - Lint your workflows.
zizmorandactionlintcatch dangerous triggers, template injection and excessive permissions. We run them as a required check. - Rotate what remains. Any secret that must stay static should have an owner and a rotation date.
For runners, the hosted GitHub runners are ephemeral, which limits persistence. Self-hosted runners should also be ephemeral (one job per runner, then destroyed) and never attached to public repositories.
Produce SBOMs and sign what you ship
The last layer answers two questions at deploy time: is this artifact the one our pipeline built, and what is inside it?
SBOMs
A software bill of materials lists every package in an artifact. We generate one per container image, attach it to the image and scan it for known vulnerabilities. Syft and Trivy both generate SPDX or CycloneDX SBOMs; Docker BuildKit can also produce SBOM and provenance attestations during the build with --sbom=true --provenance=mode=max.
Keyless signing with cosign
Sigstore's cosign supports keyless signing: in CI, cosign uses the workflow's OIDC identity to obtain a short-lived certificate, signs the image digest and records the signature in the public Rekor transparency log. There is no signing key to store or rotate.
build:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # for keyless signing
packages: write # push to GHCR
steps:
- uses: actions/checkout@<full-commit-sha> # v5
- uses: sigstore/cosign-installer@<full-commit-sha> # v3
- id: build
run: |
docker build -t ghcr.io/example-org/api:${GITHUB_SHA} .
docker push ghcr.io/example-org/api:${GITHUB_SHA}
DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' ghcr.io/example-org/api:${GITHUB_SHA})
echo "image=$DIGEST" >> "$GITHUB_OUTPUT"
- run: syft "${{ steps.build.outputs.image }}" -o spdx-json > sbom.spdx.json
- run: cosign sign --yes "${{ steps.build.outputs.image }}"
- run: cosign attest --yes --type spdxjson --predicate sbom.spdx.json "${{ steps.build.outputs.image }}"The login to GHCR and the Syft install step are omitted for brevity. Always sign the digest, not a tag, for the same reason you pin actions.
Verification happens where you deploy. Before rolling out, the deploy job (or a Kubernetes admission controller such as Kyverno or the Sigstore policy-controller) checks that the image was signed by the expected workflow:
cosign verify \
--certificate-identity "https://github.com/example-org/api/.github/workflows/build.yml@refs/heads/main" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/example-org/api@sha256:...If you prefer to stay within GitHub, actions/attest-build-provenance and actions/attest-sbom produce Sigstore-backed attestations that you verify with gh attestation verify. Either route gives you build provenance roughly in line with SLSA Build Level 2, and a clear path to Level 3 with isolated, reusable build workflows.
A Series A logistics platform we worked with adopted this sequence over three sprints: OIDC and scoped roles first, then SHA pinning and workflow linting, then signing with admission-time verification in their EKS clusters. They removed 14 long-lived AWS keys from CI in the process, several of which nobody could explain.
Summary checklist
- No long-lived cloud keys in CI; OIDC roles with
subscoped to one repository and environment or branch. -
GITHUB_TOKENdefaults to read-only; each job requests only the permissions it needs. - Separate IAM roles per purpose and environment, with narrowly scoped policies.
- Production deploys go through a protected environment with required reviewers.
- All third-party actions pinned to full commit SHAs, updated by Dependabot or Renovate.
- No
pull_request_targetworkflows that run untrusted code; no untrusted input interpolated into scripts. - Workflows linted with
zizmororactionlintas a required check. - Every image has an SBOM, is signed by digest, and is verified before it runs.
None of these controls is expensive on its own, and together they turn the pipeline from the easiest way into production into one of the best-defended.

