Compromised CI token

Your pipeline holds the keys to everything it deploys — steal its token and you inherit production.

The idea

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.

Press play to watch a long-lived deploy token leak from the pipeline, get abused, and then get contained.

How it works

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

Signals

SignalWhat a compromised CI token looks like
Deploy sourceDeploy or publish from an unexpected workflow or branch
Release pathA package version published outside the normal release process
API targetsCloud calls from the CI identity to services it never touches
Build logsA secret printed or echoed into the build output
Token lifetimeThe token used after its job already finished

Watch out for

Worked example

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.

Check yourself

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.