# Using Secrets in GitHub Actions Safely

URL: https://www.devopsness.com/blog/github-actions-secrets-guide
Author: Kiril Urbonas
Published: 2026-07-23
Updated: 2026-10-12
Categories: DevOps
Tags: CI/CD, Security, GitHub Actions, DevOps, Secrets

Summary: A practical guide to scoping GitHub Actions secrets correctly, avoiding leaks in logs, blocking fork-PR theft, and preferring OIDC over stored keys.

---
# 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
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
# 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
# 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](/blog/oidc-federation-github-actions-aws). 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](/blog/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.

