A practical guide to scoping GitHub Actions secrets correctly, avoiding leaks in logs, blocking fork-PR theft, and preferring OIDC over stored keys.
Every CI pipeline eventually needs a credential: a registry token, a database password, a cloud key. GitHub Actions gives you a secrets store, some masking, and a set of scoping options. The store is fine. The trouble is that the defaults are permissive and the masking is shallower than most people assume, so it is easy to build a workflow that quietly hands your production credentials to code you have never read.
This is the guide we wish every new engineer got before they wrote their first deploy.yml.
GitHub secrets live at three levels, and picking the wrong one is the most common structural mistake.
Repository secrets: available to every workflow in a single repo. Good for things that belong to exactly that project and nothing else, like a project-specific API token.
Environment secrets: attached to a named environment (staging, production) and only readable by a job that declares environment: production. This is the level that lets you gate access with protection rules, which we will come back to.
Organization secrets: shared across many repos, with a visibility setting that can be all repos, private repos, or an explicit allow-list. Great for a shared package-registry token. Dangerous when you set visibility to "all repositories" and forget, because a brand-new repo inherits it on day one.
The rule we follow: use the narrowest scope that works. Reach for org secrets only when the same credential genuinely belongs to many repos, and always pin visibility to a selected list.
Secrets are exposed through the secrets context. Pull them into env at the step or job level rather than sprinkling ${{ }} through your shell commands.
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Publish
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish
Notice what this does not do. It never writes ${{ secrets.NPM_TOKEN }} directly inside the run block. When you interpolate a secret into the command string, GitHub substitutes the raw value before the shell ever sees it, so the value can land in your shell history, in set -x trace output, or break parsing if it contains quotes or backticks. Passing through env hands the tool the value via the environment and keeps it out of the command text.
GitHub automatically registers every secret value and replaces it with *** in logs. That protects you from the obvious case of a tool printing a token. It does not protect you from yourself, because masking only matches the exact stored string. Secrets still leak when:
echo $TOKEN | base64 produces a new string GitHub has never seen, so it is printed in full.Treat the mask as a seatbelt, not a reason to drive into a wall. The only reliable rule is: never deliberately print a secret. If you must confirm one is set, print its length, not its value.
Here is the one that has burned real companies. There are two triggers that run on pull requests, and they are not interchangeable.
pull_request runs the workflow in a restricted context. For PRs from forks, secrets are not available and the GITHUB_TOKEN is read-only. This is exactly what you want when a stranger opens a PR: their code runs, but it cannot touch your credentials.
pull_request_target runs in the context of the base repo, with full secret access, but it checks out the merge base by default. Teams reach for it to get secrets into PR workflows, then add actions/checkout with the PR's head ref, and now untrusted fork code is executing with your production secrets in scope. That is a full credential-theft primitive, handed to anyone on the internet who can open a PR.
The anti-pattern to never ship:
# DO NOT DO THIS
on: pull_request_target
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # untrusted code
- env:
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: ./scripts/build.sh # attacker controls build.sh
For untrusted contributions, use pull_request, keep secrets out of that path, and if a fork PR genuinely needs a privileged step, split it: run the untrusted build on pull_request, then run the privileged part in a separate workflow_run job that never checks out fork code.
Environment secrets earn their keep because they can require human approval. Put production credentials behind an environment with required reviewers, and a deploy job pauses until someone signs off before the secret is ever exposed.
# Repo Settings > Environments > production:
# required reviewers: platform-team
# deployment branches: main only
jobs:
release:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Deploy
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
run: ./deploy.sh
Because the job declares environment: production, the run stops at that job until a listed reviewer approves it, and the branch filter stops the environment from being used off a random feature branch. That single gate turns an accidental push into a blocked deploy instead of a live one.
Everything above manages the risk of a stored secret. The better play is to not store long-lived cloud credentials in the first place. GitHub can present a signed OIDC token that AWS, GCP, or Azure will exchange for short-lived credentials at runtime. Nothing static sits in your secrets store, so there is nothing to leak, rotate, or accidentally echo into a fork's log.
We walk through the full setup, including the trust-policy scoping that stops other repos from assuming your role, in keyless deploys to AWS via OIDC. If you are still copying access keys into GitHub Secrets, that post is the upgrade to make first.
For the wider set of patterns this fits into, see our GitHub Actions recipes.
Scope every secret as narrowly as it will go, and pin org-secret visibility to an explicit list. Pass secrets through env, never into run interpolation, and never print them. Use pull_request, not pull_request_target, for anything touching untrusted forks. Gate production secrets behind an environment with required reviewers. And for cloud access, skip stored keys entirely and go OIDC. The safest secret is the one that does not exist to be stolen.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
A production-focused Argo CD and GitOps guide: declarative Applications and ApplicationSets, app-of-apps, sync waves and hooks, automated self-heal and prune, progressive delivery with Argo Rollouts, projects/RBAC, and secure secrets — with copy-paste examples.
Most GitHub Actions pain comes from the same handful of jobs done wrong. This is the map: the recipes that make pipelines fast, secure, and cheap.
Explore more articles in this category
Run only the CI jobs a change actually affects using if conditionals, trigger path filters, and per-job path detection in a monorepo.
A prioritized toolkit for cutting CI time: measure the critical path first, then cache, parallelize, run only what changed, and shrink the work itself.
Stop stashing long-lived AWS access keys in GitHub secrets and let OIDC hand your workflows short-lived, scoped credentials instead.
Evergreen posts worth revisiting.