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 AbuseandCICD-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/apion themainbranch 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:
- Identify every credential in the pipeline today. Classify as
OIDC-capableorstatic-only. - Replace OIDC-capable ones (AWS, GCP, Azure, Vault, Google Artifact Registry) with OIDC trust policies, scoped by
repo:owner/name:ref:.... - For remaining static secrets (3rd-party SaaS APIs), store in the CI provider's secret manager, rotate on a schedule, and scope per-environment.
- Set workflow
permissions:explicitly. Default tocontents: readand grant more only per-job. - Use environment protection rules (see concept 14) to gate production OIDC roles behind approval.
- Review self-hosted runners: isolated network segments, ephemeral VMs, no shared caches across jobs.
- Pin third-party actions by commit SHA, not tag -- same identity-by-hash rule as concept 4.
Check Yourself
- What specific failure mode does OIDC-based cloud access eliminate that stored access keys have?
- Why is
pull_request_targetdangerous if misused? - What is the minimum set of GitHub Actions permissions for a workflow that only reads code and runs tests?
- Give two reasons self-hosted runners need more care than hosted ones.
- 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
- Pro Git: Server-side hooks and per-push policy -- complementary defense: reject bad pushes before they reach the pipeline
- Pro Git: Enforcing commit format / user-based ACL -- signed-commit enforcement as a gate
See also (external)
- GitHub Actions -- OpenID Connect overview -- how OIDC works in Actions
- GitHub Actions -- Using secrets in GitHub Actions -- storing and using secrets correctly
- GitHub -- Configuring OpenID Connect in AWS -- step-by-step integration
- GitHub -- Security hardening for GitHub Actions -- defense-in-depth guide
- GitLab CI -- ID tokens and OIDC for cloud -- equivalent for GitLab runners
- OWASP -- CI/CD Top 10 -- threat model for pipelines
- Google -- SLSA threat model for CI/CD -- what signing + OIDC actually defend against