Using Secrets in GitHub Actions Safely

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.

Three scopes, three jobs #

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.

Referencing secrets correctly #

Secrets are exposed through the secrets context. Pull them into env at the step or job level rather than sprinkling ${{ }} through your shell commands.

yaml.yaml

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.

Masking is real, but shallow #

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:

You echo a secret on purpose to "debug" it. Even masked, related derived values around it can expose structure.

The secret is multiline and a tool prints one line at a time; partial lines may not match the mask.

You transform it. echo $TOKEN | base64 produces a new string GitHub has never seen, so it is printed in full.

You place it in structured output like JSON that a later step parses and re-emits.

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.

The forked-PR trap #

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:

yaml.yaml

# 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 protection rules #

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.

yaml.yaml

# 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.

The bigger move: stop storing keys at all #

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.

The call we'd make #

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.