Skip to content

Securing a CI/CD pipeline: OIDC, least privilege and signed artifacts

The hardening baseline we apply to GitHub Actions and AWS: OIDC instead of static keys, scoped permissions, SHA-pinned actions, secrets hygiene, SBOMs and keyless signing with cosign.

Written by

James Wilson

Cloud Security Engineer

Published
Reading time
7 min
Share
Image: Zaqy Al Fattah on Unsplash · Source
On this page
  1. The threat model in one table
  2. Replace static cloud keys with OIDC
  3. Apply least privilege at every layer
  4. Pin third-party actions to commit SHAs
  5. Keep secrets away from untrusted code
  6. Produce SBOMs and sign what you ship
  7. Summary checklist

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.

ThreatExamplePrimary control
Leaked long-lived credentialsAWS access key in a repo secret is exfiltratedOIDC federation, no static keys
Over-privileged pipelineA test job can deploy to productionLeast-privilege tokens and roles, environments
Compromised third-party actionA tag is moved to malicious codePin actions to full commit SHAs
Untrusted code with secretsA fork pull request reads deploy secretsSafe triggers, secret scoping
Tampered artifactsAn image is replaced in the registrySigning and verification at deploy
Unknown dependenciesA vulnerable library ships unnoticedSBOMs 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.

json
{
  "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:

yaml
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.sh

Replace <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:

yaml
- uses: actions/setup-node@<full-commit-sha> # v4.4.0

The 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 plain pull_request trigger 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 a run block 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. zizmor and actionlint catch 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.

yaml
  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:

bash
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 sub scoped to one repository and environment or branch.
  • GITHUB_TOKEN defaults 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_target workflows that run untrusted code; no untrusted input interpolated into scripts.
  • Workflows linted with zizmor or actionlint as 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.

More in Security

Security

Zero-Trust Architecture: Security in the Cloud Era

Implement zero-trust security models for cloud environments. Modern approach to security that assumes no trust by default and verifies every access request.

Emily Watson

11 min read

Need help putting this into practice?

Talk to the engineers who write these posts. A 30-minute call gets you a rough plan, a timeline and a price range for your setup, whether or not we work together.

  • Reply within one business day
  • NDA on request
  • Senior engineer on every call