DEV Community

kobima6813
kobima6813

Posted on

How to stop secrets leaking into GitHub Actions logs (and move to OIDC)

After the Railway incident I wrote a 15-minute key rotation checklist. Rotating is step one. Step two is making sure the new keys don't leak again, and CI is one of the easiest places to leak them.

Here are the three patterns I see most often, and how to fix each one.

1. Printing secrets in run: steps

- run: echo "key is ${{ secrets.DEPLOY_KEY }}"
- run: printf '%s\n' "${{ secrets.DB_PASSWORD }}"
Enter fullscreen mode Exit fullscreen mode

GitHub masks known secret values in logs, but masking is best-effort. If the value is base64-encoded, split, URL-encoded or otherwise transformed, the masked form no longer matches and the real value can show up in the log.

Fix: never echo secrets. Pass them through env: on the step that needs them and let the tool read the variable:

- name: Deploy
  env:
    DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
  run: ./deploy.sh   # reads $DEPLOY_KEY, never prints it
Enter fullscreen mode Exit fullscreen mode

If you create a derived secret at runtime, mask it explicitly before anything can print it:

echo "::add-mask::$DERIVED_TOKEN"
Enter fullscreen mode Exit fullscreen mode

2. Secrets in workflow-level env:

env:
  OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
jobs:
  build:
    steps:
      - uses: some/third-party-action@v1   # can read OPENAI_API_KEY too
Enter fullscreen mode Exit fullscreen mode

A secret in top-level env: is available to every step of every job, including third-party actions that have nothing to do with it.

Fix: scope the secret to the single step that needs it (as in the example above).

3. Long-lived cloud keys instead of OIDC

- uses: aws-actions/configure-aws-credentials@v4
  with:
    aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
    aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
    aws-region: eu-west-1
Enter fullscreen mode Exit fullscreen mode

A static key stored as a secret is exactly the thing you have to rotate after a breach. With OIDC, GitHub issues a short-lived token per job and your cloud exchanges it for temporary credentials. There is no long-lived key to steal.

AWS:

permissions:
  id-token: write
  contents: read
steps:
  - uses: aws-actions/configure-aws-credentials@v4
    with:
      role-to-assume: arn:aws:iam::123456789012:role/gha-deploy
      aws-region: eu-west-1
Enter fullscreen mode Exit fullscreen mode

You create an IAM OIDC identity provider for token.actions.githubusercontent.com and a role whose trust policy limits which repo/branch can assume it.

GCP (Workload Identity Federation):

permissions:
  id-token: write
  contents: read
steps:
  - uses: google-github-actions/auth@v2
    with:
      workload_identity_provider: projects/123/locations/global/workloadIdentityPools/gh/providers/github
      service_account: deployer@my-project.iam.gserviceaccount.com
Enter fullscreen mode Exit fullscreen mode

Azure: use azure/login with client-id, tenant-id and subscription-id plus a federated credential on the app registration, instead of a creds JSON secret.

Always restrict the trust policy to your repo (and ideally branch or environment). An OIDC role that any repo can assume is worse than no OIDC.

Catch these automatically

I built actions-harden, a zero-dependency CLI and GitHub Action that flags these patterns (plus unpinned actions, missing permissions and script injection). Real output on a deliberately bad workflow:

secrets_bad.yml:7: [MEDIUM] workflow-env-secret: secrets.OPENAI_API_KEY is set in workflow-level env:, exposing it to every step of every job (including third-party actions). Scope it to the step that needs it.
secrets_bad.yml:15: [MEDIUM] long-lived-cloud-key: Long-lived AWS key from secrets. Prefer short-lived OIDC credentials: aws-actions/configure-aws-credentials with 'role-to-assume' + 'permissions: id-token: write'.
secrets_bad.yml:20: [MEDIUM] long-lived-cloud-key: Long-lived GCP key from secrets. Prefer short-lived OIDC credentials: google-github-actions/auth with 'workload_identity_provider' (Workload Identity Federation).
secrets_bad.yml:21: [HIGH] secret-in-log: secrets.DEPLOY_KEY is printed in a run: step. Masking is best-effort (encoded/split values leak); never echo secrets.
Enter fullscreen mode Exit fullscreen mode

Add it to CI:

permissions:
  contents: read
jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: kobima6813/actions-harden@main
        with:
          fail-on: high
Enter fullscreen mode Exit fullscreen mode

Quick checklist

  • [ ] No echo/printf of ${{ secrets.* }} in run: steps
  • [ ] Secrets scoped to the step, not workflow-level env:
  • [ ] ::add-mask:: for any secret derived at runtime
  • [ ] AWS/GCP/Azure via OIDC, static keys deleted
  • [ ] OIDC trust limited to your repo/branch
  • [ ] permissions: set explicitly, id-token: write only where needed

If you're still rotating after the Railway breach, start with the checklist, then do this so you don't have to do it twice.

Top comments (1)

Collapse
 
manojkagitha profile image
Manoj Kumar Kagitha •

The Azure federated credential point is the one people skip. I moved our pipelines off the creds JSON a while back and the real win was not rotation, it was audit. Every deployment mapped to a repo and branch.