Server

Handling Secrets in CI Without Leaking Them

By Prabath Thalangama· October 24, 2026· 3 min read
#ci-cd#security#secrets

Introduction

Every CI system lets you store secrets and inject them as environment variables. The danger isn't storage — it's the dozen ways a secret escapes at runtime.

How secrets leak

  • set -x / debug mode printing the command line with the value in it.
  • echo "$TOKEN" for debugging, left in.
  • A tool printing the value in an error message ("auth failed for token ghp_xxx").
  • Build artifacts — a .env file, a compiled config, a Docker layer (ARG SECRET is recoverable from history).
  • env / printenv dumped in a "collect diagnostics" step.
  • Forked pull requests — a malicious PR editing the workflow to exfiltrate secrets.
  • Cache — a secret written into a cached directory, restored on another branch.

Masking and its limits

GitHub Actions (and GitLab, etc.) mask registered secrets in logs — occurrences are replaced with ***. Limits:

  • Only exact string matches. A base64 or URL-encoded form of the secret isn't masked. A secret split across lines isn't masked.
  • Multi-line secrets (private keys) mask poorly; register each line, or better, write to a file.
  • Masking is log-only. Artifacts, caches, and outbound network calls are not touched.
  • add-mask a value you compute at runtime: echo "::add-mask::$DERIVED".

Treat masking as a safety net, not a control.

OIDC: stop storing cloud keys

The biggest win. Instead of a long-lived AWS_ACCESS_KEY_ID secret, the CI job requests a short-lived token from your cloud via OpenID Connect federation:

permissions:
  id-token: write        # allow the job to request an OIDC token
  contents: read

steps:
  - uses: aws-actions/configure-aws-credentials@v4
    with:
      role-to-assume: arn:aws:iam::123456789012:role/ci-deployer
      aws-region: eu-west-1

The cloud trust policy restricts which repo, branch, and environment can assume the role. No secret to leak, credentials expire in an hour, and every assumption is logged in CloudTrail. GCP and Azure have equivalents.

Least privilege and environments

  • Scope secrets to environments (production) with required reviewers, so a deploy secret isn't available to every PR run.
  • Give the CI role only the permissions the pipeline needs.
  • Separate read (build/test) from write (deploy) credentials; most jobs need read only.

Forked PR safety

  • pull_request from a fork runs with no secrets and a read-only token by default — keep it that way.
  • Never use pull_request_target (which does have secrets) to run untrusted PR code. If you need labels/comments on forked PRs, do it in a separate workflow triggered by workflow_run that doesn't check out the PR head.
  • Require approval for first-time contributors' workflow runs.

Verification and troubleshooting

# Scan the repo + history for committed secrets
gitleaks detect --source . --redact
trufflehog git file://. --only-verified

# Inspect a built image for baked-in secrets
docker history --no-trunc myimage
dive myimage
  • Secret appears as *** but the deploy still fails "invalid credentials" — a trailing newline in the stored secret, or it's expired/rotated. Re-store with printf %s semantics.
  • Secret visible despite masking — it was transformed (base64, json-encoded) before printing. Mask the transformed form too, or don't print it.
  • configure-aws-credentials fails "Not authorized to perform sts:AssumeRoleWithWebIdentity" — the IAM trust policy's sub condition doesn't match (repo:org/name:ref:refs/heads/main vs environment form), or id-token: write permission is missing.
  • Fork PR can't run needed checks — that's the design; move secret-requiring steps to a post-merge or workflow_run job.
  • Old access keys still active — after moving to OIDC, delete the IAM user / rotate out the stored secret. It's not gone until you remove it.
PrabathStuck on something this site can't fix?Reach out to Prabath directly on LinkedIn.