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 }}"
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
If you create a derived secret at runtime, mask it explicitly before anything can print it:
echo "::add-mask::$DERIVED_TOKEN"
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
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
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
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
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.
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
Quick checklist
- [ ] No
echo/printfof${{ secrets.* }}inrun: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: writeonly 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)
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.