Your pipeline holds the keys to everything it deploys: steal its token and you inherit production.
A CI/CD pipeline is trusted machinery: it builds your code, then reaches out with powerful, long-lived secrets (cloud deploy keys, container-registry creds, package-publish tokens) to ship the result. Whoever holds those secrets can do anything the pipeline can do.
A compromised CI token is what happens when that secret leaks: a poisoned dependency reads the environment, a fork's pull request runs with secrets attached, or a token gets echoed into a public build log. The attacker now acts as the pipeline's identity (publishing packages, deploying to prod, pushing code) and nothing about the request looks unusual. The durable fix is to make the secret nearly worthless to steal: short-lived, federated, and scoped to one job.
The failure mode: a static deploy token sits in CI secrets forever, broadly scoped, and gets handed to every job, including builds running untrusted code. One leak and the attacker has a working credential for as long as nobody rotates it.
The fix is to stop storing a long-lived secret at all. The job mints a short-lived token at runtime by federating its own verified identity (OIDC), scoped to exactly one action, and untrusted fork builds never receive any secret. A leaked token is useless minutes later.
# GitHub-Actions-style deploy job — no static secret
permissions:
contents: read # least privilege by default
id-token: write # allow minting an OIDC token
on:
push:
branches: [main] # deploy only from trusted refs,
# NOT pull_request from forks
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # pin to a SHA in prod
- name: Mint short-lived cloud creds via OIDC
uses: cloud/assume-role@v2
with:
role: arn:cloud:iam::role/deploy-app-only
ttl: 900 # 15 minutes, then dead
# no access-key secret — identity is federated
- run: ./deploy.sh # scoped to deploy, nothing else
| Signal | What a compromised CI token looks like |
|---|---|
| Deploy source | Deploy or publish from an unexpected workflow or branch |
| Release path | A package version published outside the normal release process |
| API targets | Cloud calls from the CI identity to services it never touches |
| Build logs | A secret printed or echoed into the build output |
| Token lifetime | The token used after its job already finished |
echo $TOKEN or a verbose flag can dump credentials into logs that outlive the job and may be public. Mask secrets and never print them.@v3 can be moved to malicious code; a poisoned dependency runs inside your runner with its secrets. Pin actions and dependencies to a SHA or hash.A build installs a dependency that was transitively poisoned: a sub-dependency added a post-install script that reads process.env, finds NPM_PUBLISH_TOKEN, and quietly POSTs it to an attacker host. Because the token is a long-lived publish credential, the attacker waits a day, then publishes a backdoored patch version. It's downloaded ~50,000 times before a maintainer notices an unexpected release and pulls it.
Contrast the OIDC design: there is no stored publish token to read. The job mints a token with a 15-minute TTL, scoped to deploy only: it can't publish packages at all. Even if the exfiltration script grabs it, the credential is dead minutes later and can't reach the registry. The same leak that caused a 50,000-install incident becomes a non-event.
Your CI deploy token was printed in a public build log. Beyond rotating it right now, what's the best durable fix?
Coach note: rotation and private logs shrink the window, but a long-lived secret still leaves one open. The durable move is to stop having a stealable standing credential at all.