To deploy anything, your pipeline needs credentials: cloud keys, registry tokens, signing keys. That's unavoidable, and it's exactly why the pipeline is where attackers go hunting first. A successful CI compromise that yields a deploy key is worth far more than one that yields a single bug. This lesson is about getting those secrets into the build without leaving copies lying around for someone to pick up.
If you've already read Secrets Management, this is the same discipline applied to the place it most often goes wrong.
How they leak
Pipeline secrets rarely leak through a dramatic break-in. They leak through ordinary build mechanics:
- Build logs. A command echoes its arguments, a debug print, a verbose flag.
- Environment dumps. A stray
env | sortprints every variable, secrets included. - Baked into artefacts. A Docker image with a
.envinside, a frontend build that inlinedprocess.env. - Pull request exploitation. A PR triggers CI, and CI has the secrets.
- Cache poisoning. A low-privilege job slips a script into a cache that a privileged job later trusts.
Provide them properly
Use the platform's secret store, not a checked-in file: GitHub secrets, GitLab CI variables, Jenkins credentials.
Scope them as narrowly as they'll go, so the secret only exists in the job that genuinely needs it:
# GitHub - production secrets bound to the production environment
jobs:
deploy:
environment: production
Inject only where used, rather than exposing a secret to the whole workflow:
- name: Deploy
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
Better still, store nothing
OIDC federation lets the pipeline mint a short-lived token at run time instead of holding a long-lived secret at all. There's nothing stored to steal and nothing to rotate:
permissions:
id-token: write
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/deploy
Keep them out of the logs
When you do derive a value from a secret, mask it before it can be printed:
TOKEN=$(echo ${{ secrets.ENCODED }} | base64 -d)
echo "::add-mask::$TOKEN"
And steer clear of the flags that dump everything: set -x, curl -v, DEBUG=*.
Keep them out of Docker layers
# BAD - persists in the image layer
ARG DB_PASSWORD
# GOOD - BuildKit mount, never written to a layer
RUN --mount=type=secret,id=db_password cat /run/secrets/db_password
Rotate on a schedule, revoke on a leak
Rotate API keys roughly every 90 days and deploy credentials every 30 to 90. OIDC tokens are short-lived, so they need no rotation at all, which is half the reason to prefer them. And if a secret does leak, the response is the same as anywhere else: revoke immediately, rotate, audit the logs, find the root cause.
The shape of a healthy pipeline: platform secret stores, narrow scoping, OIDC wherever you can, masked logs, nothing baked into layers, and a fast revoke when something slips.
