What is it? #
Pipelines need credentials: registry tokens, deployment keys, cloud access, API keys for tests.
Those credentials are attractive targets, because a pipeline with production access is effectively production access.
The principles are the same as everywhere else, applied to an environment that runs code from pull requests: least privilege, short lifetimes, scoping by environment, and never in the repository.
The strongest available improvement is replacing long-lived keys with short-lived tokens obtained at run time.
Think of it like this #
Giving a contractor a key that opens one door, works for one afternoon, and logs every use.
Handing over a master key that never expires is quicker and means an ex-contractor still has access a year later.
Simple example #
A pipeline that deploys to production. It uses a scoped registry token, a deployment key limited to one command, and short-lived cloud credentials obtained per run rather than stored.
Code #
# Scoping: production secrets belong to the production environment
jobs:
deploy-production:
environment: production # only this environment's secrets are available
steps:
- run: ./deploy.sh
env:
DEPLOY_KEY: ${{ secrets.PRODUCTION_DEPLOY_KEY }}
# A job that does not declare the production environment cannot read those secrets,
# including anything triggered from a fork's pull request.
# The best option: short-lived credentials via OIDC, nothing stored
jobs:
deploy:
permissions:
id-token: write # request an identity token
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/deploy-role
aws-region: eu-west-1
# The cloud provider trusts this repository and workflow, and issues
# credentials valid for minutes. There is no long-lived key to leak.
# Least privilege for the built-in token
permissions:
contents: read # default to read-only
packages: write # only what this job needs
id-token: write
# Handling secrets inside a job
echo "$REGISTRY_TOKEN" | docker login ghcr.io -u ci --password-stdin # not on the command line
echo "::add-mask::$DERIVED_VALUE" # mask a value computed at run time
# Never do this
# echo "Deploying with token $DEPLOY_TOKEN" # printed into the log
# curl -H "Authorization: Bearer $TOKEN" ... -v # verbose prints headers
# env # dumps everything
Pull requests from forks
A fork's pull request runs workflow code that a stranger wrote. If it
could read your secrets, it could print them.
Platforms restrict this by default: secrets are not available to fork
pull requests. Do not work around it. If a check genuinely needs
credentials, run it after review, on a separate trusted trigger.
After a secret is exposed
1. rotate it immediately — this is the only step that actually helps
2. check the audit logs for use between exposure and rotation
3. remove it from the code and the history
4. add a scanner to the pipeline so it is caught next time
Deleting the commit does nothing. Assume it was captured.
How it works #
Environment-scoped secrets are only readable by jobs declaring that environment, which means a test job cannot read production credentials even accidentally.
OIDC-based credentials are the significant improvement. The cloud provider trusts a specific repository and workflow, and issues credentials valid for minutes. There is no stored key, so there is nothing to leak or rotate.
Narrowing permissions limits the built-in token. The default is broader than most jobs require, and reducing it is a one-line change.
Masking prevents accidental printing. Platform-provided secrets are masked automatically; values derived at run time are not, which is why add-mask exists.
Passing tokens via stdin rather than on the command line keeps them out of process listings and logs, which frequently capture commands.
Verbose flags are a common leak. curl -v prints request headers, including the authorization header, straight into the build log.
The fork restriction exists because a pull request can modify the workflow file. Working around it by running fork code with secrets available has caused real compromises.
Rotation is the only response to exposure that matters. The secret must be assumed captured the moment it was visible.
Real-world use #
CI systems are a recognised supply-chain target. A pipeline with production credentials is an attractive route in, and compromised build systems have been used to reach production more than once.
OIDC is now well supported by the major cloud providers and is the recommended approach, replacing stored access keys entirely.
Third-party actions run with access to your job. Pinning them to a commit SHA rather than a moving tag prevents a compromised update running in your pipeline.
Secret scanning in the pipeline catches credentials before they are committed, and most platforms also scan pushed code and alert on findings.
Rotation should be routine rather than incident-driven. Credentials belonging to people who have left, or keys older than a year, are worth replacing on a schedule.
Common mistakes #
- Long-lived cloud keys stored as secrets when OIDC is available.
- Secrets available to every job rather than scoped to an environment.
- Printing tokens through verbose flags or debug output.
- Using floating tags for third-party actions instead of pinned commits.
- Deleting an exposed secret from the code without rotating it.
Practice #
Audit a pipeline: list every secret, what uses it, and what it grants. Scope production secrets to a production environment, narrow the token permissions, and replace one long-lived credential with a short-lived one if your provider supports it.