GitHub Actions in Depth
Topic 56

Secrets and Encrypted Values

CI/CD

Secrets are encrypted values injected into a workflow at runtime and masked from logs. They live at three scopes — repository, organization, and environment — and there is a fourth credential you get for free: the built-in GITHUB_TOKEN, minted per run and scoped by the permissions: you declare.

The recurring failure mode is treating "masked" as "safe." Masking redacts the exact literal value, nothing more. Transform a secret — base64, a substring, a hash — and the transformed form prints in clear text. Handling secrets well is mostly about never putting them somewhere a transformation or a fork can expose them.

The Three Scopes

Repository secrets are the default and visible to every workflow in the repo. Organization secrets are defined once and shared across repos under an access policy, so you stop duplicating the same value. Environment secrets are gated by an environment's protection rules, which is where production credentials belong.

GITHUB_TOKEN

This token is generated at the start of each run and expires when the run ends, so there is nothing long-lived to leak. Scope it with permissions: and prefer it over a stored personal access token for operations on the same repository.

Scoping the token down and reading a secret
permissions:
  contents: read
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ./deploy.sh
        env:
          API_KEY: ${{ secrets.API_KEY }}

Masking

Actions scans log output and redacts the exact secret string. It cannot see through encodings: echo "$SECRET" | base64 emits an unmasked value because the bytes no longer match the literal Actions is watching for. The defense is simple — never print a secret or anything derived from one.

Fork and Pull Request Behavior

A pull_request workflow triggered by a fork does not receive secrets, because a malicious fork could otherwise exfiltrate them through the PR's own code. Logic that assumes a secret is present will see an empty value on fork PRs; write it to degrade gracefully rather than fail confusingly.

Consuming Secrets

Reference a secret as ${{ secrets.NAME }} and feed it into env: or an action's with:, never into an echo or a run: that prints it. Anything that should not be encrypted — a region, a non-sensitive flag — belongs in vars, which is plaintext and visible in logs and settings.

Common Mistakes
  • Echoing a secret or a base64-encoded form of it — masking catches only the exact literal, so the transformed value prints in clear text.
  • Storing a long-lived cloud key in repo secrets when OIDC could mint a short-lived credential per run instead.
  • Granting GITHUB_TOKEN blanket write-all permissions for a job that only reads, so every step and action runs with write access.
  • Writing logic that assumes a secret is present in a fork-triggered pull_request run, which breaks when the value is empty.
  • Putting a sensitive value in vars instead of secrets, leaving it plaintext in logs and settings.
Best Practices
  • Set permissions: to the minimum, usually contents: read, and elevate per-job only where a write is needed.
  • Keep production credentials in environment secrets behind protection rules, not at repo level.
  • Reference secrets through env: or with: and never echo them or any derived value.
  • Use org secrets with repository access policies instead of copying the same secret into many repos.
  • Replace long-lived cloud secrets with OIDC federation wherever the cloud provider supports it.
Comparable toolsGitLab CI/CD masked/protected CI/CD variables and CI_JOB_TOKENCircleCI contexts and project env varsAzure Pipelines secret variables, variable groups, System.AccessTokenJenkins Credentials plugin

Knowledge Check

Why can a base64-encoded secret still leak into logs?

  • Masking redacts only the exact literal value, so a transformed form prints in clear text
  • Base64 is a known-weak cipher that the Actions runner cannot decode and re-mask
  • Encoded secrets bypass the encryption-at-rest layer that protects the stored secret values
  • Masking only applies to env: values, never to text printed by run: steps

Why are secrets withheld from a fork-triggered pull_request run?

  • The fork's own code runs in that workflow and could exfiltrate any secret it receives
  • Forks run on runners in a different network region with no route at all to the secret store
  • Secrets are only ever decrypted for runs triggered on the default branch
  • Pull request events from forks never trigger any workflow run at all

What is the security gain of scoping GITHUB_TOKEN permissions?

  • A read-only token limits the blast radius of a compromised step or action to read access
  • It makes the minted token live considerably longer and stay valid across many later workflow runs
  • It encrypts the token a second time before it is handed to steps
  • It allows the scoped token to be used from fork pull requests

Where should production credentials live?

  • In environment secrets gated by protection rules, so PR CI jobs cannot read them
  • In plain repository secrets, which every workflow in the repo can already read freely
  • In vars, on the basis that deploy values are not actually sensitive
  • In the workflow YAML file itself, stored as an encrypted inline string

You got correct