Skip to main content
SecurityAugust 25, 20266 min read

How to Handle Secrets in CI Without Leaking Them Into Logs

The safest credential in your pipeline is the one that does not exist between jobs. OpenID Connect lets a workflow authenticate directly to a cloud provider and receive a token that expires on its own, which removes the stored key entirely. Masking is the backstop for everything left over, and it is worth knowing exactly where it stops working, because it relies on finding an exact match for the value. This guide covers the short-lived credential setup, the limits of redaction, and what to actually do in the ten minutes after a secret reaches a log.

Most secret-handling advice in CI is about hiding a long-lived credential well. That is the wrong end of the problem. A deploy key stored in a repository is valid at three in the morning six months from now, whether or not anybody is still watching the account it belongs to, and every log line, artefact and cache in between is a place it might have escaped to.

The better move is to stop storing it. Then spend what is left of your effort on the credentials that genuinely cannot be replaced that way.

Replace the stored key with a short-lived one

GitHub Actions can present an OpenID Connect token to a cloud provider that trusts it, and the provider hands back an access token. GitHub's own summary of what you get is that you will not need to duplicate your cloud credentials as long-lived GitHub secrets, and that the provider issues a short-lived access token that is only valid for a single job and then automatically expires.

The workflow side is small. The job asks for the token, and the provider's action exchanges it.

# .github/workflows/deploy.yml
name: AWS example workflow
on:
  push
env:
  BUCKET_NAME : "BUCKET-NAME"
  AWS_REGION : "AWS-REGION"
permissions:
  id-token: write
  contents: read
jobs:
  S3PackageUpload:
    runs-on: ubuntu-latest
    steps:
      - name: Git clone the repository
        uses: actions/checkout@v6
      - name: configure aws credentials
        uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502
        with:
          role-to-assume: ROLE-TO-ASSUME
          role-session-name: samplerolesession
          aws-region: ${{ env.AWS_REGION }}
      - name: Copy index.html to s3
        run: |
          aws s3 cp ./index.html s3://${{ env.BUCKET_NAME }}/

Two details in that block do real work. id-token: write is what allows the job to request the token at all, and pinning the action to a full commit SHA rather than a tag means the code that handles your credentials cannot change under you.

Scope the trust properly

The token GitHub issues carries a subject claim, and the trust policy on the cloud side is where you decide which workflows that claim will satisfy. The classic format looks like repo:octo-org/octo-repo:ref:refs/heads/demo-branch for a branch, repo:octo-org/octo-repo:ref:refs/tags/demo-tag for a tag, repo:octo-org/octo-repo:pull_request for a pull request, and repo:octo-org/octo-repo:environment:Production for a job tied to an environment.

Match on the exact subject you mean. A policy that accepts any ref in the repository grants production access to anything anyone can push a branch for.

There is a change here that will catch people who copy an old trust policy. Repositories created after July 15, 2026 use an immutable default subject format that includes the owner and repository IDs, shaped like repo:OWNER@OWNER-ID/REPO@REPO-ID:ref:refs/heads/BRANCH. Older repositories keep the previous format unless they opt in. Check which one your repository issues before you write the policy, and expect the claim to change if you opt in later.

The issuer is https://token.actions.githubusercontent.com, and for AWS the audience to configure is sts.amazonaws.com.

What masking does, and where it stops

For the secrets that remain, GitHub redacts values it knows about from workflow logs. The mechanism has a limit that is easy to walk into, and the documentation states it directly, saying that structured data can cause secret redaction within logs to fail, because redaction largely relies on finding an exact match for the specific secret value.

That single sentence explains most real leaks. Store a JSON service account file as one secret and the log prints its fields individually, none of which is an exact match for the whole blob. Base64 a token to pass it between steps and the encoded form is a different string. Read a password out of a JSON API response mid-job and nothing ever registered it.

Anything derived from a secret has to be registered as one. GitHub is explicit that registering secrets applies to any sort of transformation or encoding as well.

# register a value derived at runtime so it is redacted if it ever appears
SESSION_TOKEN=$(./mint-token.sh)
echo "::add-mask::$SESSION_TOKEN"
echo "SESSION_TOKEN=$SESSION_TOKEN" >> "$GITHUB_ENV"

Never use structured data as the value of a secret. Split the JSON into individual secrets, or have the workflow fetch it from a secret manager and mask the fields it uses.

The leaks that are not log lines

A secret passed as a command-line argument is visible to anything that can read the process table on the same machine, and GitHub names this directly, noting that some jobs use secrets as command-line arguments which another job on the same runner can see with ps x -w. Pass secrets through environment variables or files, and run one job per machine where you can.

Cut the blast radius of the token you did not choose, as well. GITHUB_TOKEN is granted to every workflow, and the sound default is read access to repository contents only, raised per job where a job genuinely needs more.

permissions: {}          # everything set to none

jobs:
  test:
    permissions:
      contents: read     # this job needs nothing else

Any scope you do not name is set to none once you specify the key at all.

One protection you get for free is that, with the exception of GITHUB_TOKEN, secrets are not passed to the runner when a workflow is triggered from a forked repository. The way teams lose that protection is by reaching for pull_request_target, which GitHub warns exposes the repository to security compromises when it is used with the checkout of an untrusted pull request.

When one does reach a log

Assume it is public from the moment it is written. The instruction is short, and it is that if an unredacted secret is sent to a workflow run log, you should delete the log and rotate the secret.

Do the rotation first. Revoking the credential at the provider is what actually stops it being used, and deleting the log afterwards is housekeeping. A stored credential does not expire because the job finished, and it stays valid until somebody revokes it. While you are there, check the artefacts and caches from the same run, since a value that reached the log very likely reached a file too.

Then work out why redaction did not catch it. In almost every case the answer is that the value in the log was not the value that was registered, and the fix is a call to ::add-mask:: at the point the derived value was created. Failing that, it is an argument for moving the credential to OIDC so that the next copy of it is worthless within the hour.

Our security and compliance work covers pipeline credential handling alongside the rest of the estate, and CI/CD pipeline setup is where the OIDC trust policies and least-privilege tokens get built in from the start.

Talk to the engineer who will own your stack.

No account managers, no offshore handoff. Senior DevOps, direct. Tell us what you are dealing with and you get a straight answer.