Skip to main content

Pipeline Security: Secrets, OIDC for Cloud, Least-Privilege Runners

What This Concept Is

The CI/CD pipeline is a production system. It has write access to the registry, to cloud accounts, and to production infrastructure. Treating it like a development convenience is how breaches happen. Three specific disciplines:

  • Secrets management. Credentials used by the pipeline (API keys, signing keys, deploy tokens) must live in a secret store, be injected only where needed, never logged, and rotated.
  • OIDC for cloud access. Instead of storing long-lived cloud credentials (AWS access keys, GCP service-account JSON) as CI secrets, the pipeline requests a short-lived token via OpenID Connect, authenticated by the CI provider itself. The cloud trusts the CI's identity, not a copy-pasted key.
  • Least privilege for runners. The process that executes your pipeline (a GitHub runner, a GitLab runner, a self-hosted VM) should have the minimum permissions needed for the job at hand -- and no more. The OWASP CI/CD Top 10 names this class of issue as CICD-SEC-3: Dependency Chain Abuse and CICD-SEC-7: Insecure System Configuration.

Why It Matters Here

A CI/CD pipeline that can deploy to production is, from an attacker's perspective, equivalent to production access. Every real incident involving CI systems in the last few years boils down to:

  • a long-lived static credential leaked (committed to a public repo, logged, exfiltrated from a compromised dependency)
  • an over-privileged runner that ran an attacker's workflow with production permissions
  • secrets accessible to PRs from forks, which then exfiltrated them
  • a compromised third-party action whose source was re-pointed at a tag someone had uses: @v1'd against

OIDC-based cloud access eliminates the first category. Scoped permissions and runner isolation address the second and third. Pinning actions by digest addresses the fourth -- same digest-is-identity discipline as concept 4.

Concrete Example: OIDC from GitHub Actions to AWS

Before (bad pattern -- long-lived access keys as secrets):

- uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1

Those secrets are permanent, copyable, and a nightmare to rotate.

After (OIDC -- short-lived, per-run, no stored secret):

permissions:
id-token: write # request an OIDC token
contents: read

jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-deploy-api
aws-region: us-east-1
- run: aws s3 sync ./dist s3://acme-api-assets/

On the AWS side, the IAM role has a trust policy that accepts tokens from GitHub's OIDC issuer:

{
"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"
},
"StringLike": {
"token.actions.githubusercontent.com:sub":
"repo:acme/api:ref:refs/heads/main"
}
}
}]
}

Effect:

  • only workflows in acme/api on the main branch can assume the role
  • each assumption is a short-lived session (default 1 hour)
  • there is no secret to leak; the trust is to GitHub's OIDC issuer, not to any static credential
  • revoking access = deleting the role's trust policy entry; no rotation of a dozen systems

GitLab has the equivalent via ID tokens; CircleCI, Buildkite, and most modern CI providers expose OIDC issuers that the same cloud trust policies can accept.

Common Confusion / Misconception

"Secrets are safe in repo secrets." Repo secrets are encrypted at rest, not logged, and not exposed to forks by default -- but they are still long-lived strings that any workflow with permission can exfiltrate via a single curl. Combined with malicious PRs or a compromised action, they leak. Prefer OIDC for anything that supports it.

"pull_request workflows from forks can read our secrets." They cannot, by default -- GitHub passes pull_request triggers no secrets from forks. But the pull_request_target trigger runs in the base-repo context with secrets; abusing this has been a real attack vector (the pwn request class). Do not run untrusted code in pull_request_target.

"The runner is sandboxed, I can do anything in it." GitHub-hosted runners are fresh VMs per job -- mostly safe. Self-hosted runners are often not sandboxed; a malicious workflow can persist, read other workflows' secrets on disk, and pivot. Never attach self-hosted runners to public repos without isolation.

"One service-account key shared across all pipelines is fine." Multi-tenant static credentials are the worst case: one leak breaks every pipeline. Scope at least per-service, ideally per-environment.

"We log secret values to debug." GitHub and GitLab redact known secrets from logs, but redaction is best-effort. Base64-encoded secrets, secrets transformed in memory, or secrets written to files copied into logs all bypass redaction. Do not log secret-derived data.

"OIDC is just assumeRole, our old setup already does that." OIDC-based role assumption differs from traditional sts:AssumeRole in that the caller identity is the CI run itself, verified by the cloud via the OIDC JWT. There is no pre-shared secret anywhere in the chain. If your old setup had AWS_ACCESS_KEY_ID somewhere, it was not this.

How To Use It

For a fresh pipeline with cloud access:

  1. Identify every credential in the pipeline today. Classify as OIDC-capable or static-only.
  2. Replace OIDC-capable ones (AWS, GCP, Azure, Vault, Google Artifact Registry) with OIDC trust policies, scoped by repo:owner/name:ref:....
  3. For remaining static secrets (3rd-party SaaS APIs), store in the CI provider's secret manager, rotate on a schedule, and scope per-environment.
  4. Set workflow permissions: explicitly. Default to contents: read and grant more only per-job.
  5. Use environment protection rules (see concept 14) to gate production OIDC roles behind approval.
  6. Review self-hosted runners: isolated network segments, ephemeral VMs, no shared caches across jobs.
  7. Pin third-party actions by commit SHA, not tag -- same identity-by-hash rule as concept 4.

Check Yourself

  1. What specific failure mode does OIDC-based cloud access eliminate that stored access keys have?
  2. Why is pull_request_target dangerous if misused?
  3. What is the minimum set of GitHub Actions permissions for a workflow that only reads code and runs tests?
  4. Give two reasons self-hosted runners need more care than hosted ones.
  5. Why pin third-party actions by SHA rather than by tag?

Mini Drill or Application

Audit one real pipeline you have access to. Answer:

  • list every credential in secrets
  • for each: is it OIDC-capable in the target system?
  • what is the blast radius if that credential leaks (what can it do, for how long)?
  • what would it take to replace it with OIDC?

Pick the highest-blast-radius static credential. Design the migration. A common win: the main cloud-deploy key is usually the one you should replace first.

Read This Only If Stuck

See also (external)