Skip to content

Secrets & Environments

A build/test pipeline can run with nothing sensitive at all. The moment a workflow needs to talk to something real — push an image to a registry, deploy to a cloud account — it needs a credential. That credential is a secret, and where it's allowed to be used is controlled by an environment.

TL;DR — in 30 seconds:

  • A secret is encrypted at rest and masked automatically in logs; a plain workflow variable is neither — never put anything sensitive in a plain variable.
  • Scope a secret as narrowly as possible — organization → repository → environment — so a compromised workflow's blast radius stays small.
  • The default GITHUB_TOKEN is auto-issued and short-lived; its permissions come from the workflow's own permissions: key, not from whatever you assume a token can do.

1. Secrets vs. plain variables

Plain workflow variable Secret
Stored as Plain text in the workflow/config Encrypted at rest
Referenced as ${{ vars.NAME }} ${{ secrets.NAME }}
Appears in logs Yes, if you print it Masked automatically wherever it would appear
Fine for Non-sensitive config (a region name, a feature flag) Tokens, keys, passwords

The masking is automatic and applies to any exact match of the secret's value in a log line — but it isn't a substitute for judgment: a secret that's transformed (base64-encoded, split into pieces, echoed indirectly) can still leak, because the masker only catches the literal value.

2. Where a secret can be scoped

flowchart TB
    Org["Organization secret<br/>(shared across many repos)"] --> Repo["Repository secret<br/>(this repo only)"]
    Repo --> Env["Environment secret<br/>(only visible to jobs targeting that environment)"]

A secret can live at the organization level (shared across many repos), the repository level (this repo only), or the environment level (only visible to a job that explicitly targets that environment). The narrower the scope, the smaller the blast radius if a workflow is ever compromised — an environment secret for "production" is invisible to a job that never declares environment: production.

3. The default GITHUB_TOKEN

Every workflow run is automatically issued a short-lived GITHUB_TOKEN — you don't create or store it yourself. Its permissions (what it can read or write in the repo) are scoped by the workflow's own permissions: key and expire when the job ends. It's what lets a workflow do things like comment on a PR or push a tag without you wiring up a personal credential for routine, in-repo actions.

4. What an environment adds

An environment (e.g. staging, production) is more than a label a job attaches to itself. Declaring environment: production on a job can gate that job behind:

  • Required reviewers — the job pauses and waits for an approval before it runs.
  • Wait timers — a mandatory delay before the job is allowed to start.
  • Environment-scoped secrets — only visible to jobs that target that specific environment.
flowchart TB
    Job["Job declares<br/>environment: production"] --> Reviewers{"Required reviewers<br/>approve?"}
    Reviewers -->|No| Paused["Job stays paused"]
    Reviewers -->|Yes| Timer["Wait timer elapses"]
    Timer --> Run["Job runs —<br/>environment secrets now visible"]

This is why a real pipeline's deploy-to-production job almost always sits behind an environment, while deploy-to-staging might not: the blast radius of a mistake is completely different, and the workflow mechanics let you encode that difference directly rather than trusting a human to "be careful."

5. How this connects

  • Which secrets are even reachable depends on which job you're in and what needs: chain got it there — see Jobs, steps & runners.
  • The trust distinction between a push and a pull_request from a fork (§3 of Triggers & events) is exactly why you'd never want a fork-triggered workflow to have access to a production environment's secrets.
  • The deploy stage that actually consumes these secrets is covered in Build → test → deploy.

Common gotchas

  • Assuming masking makes a secret fully safe to print or transform. Fix: masking only catches an exact match of the literal value — a base64-encoded, split, or otherwise transformed copy still leaks, because the masker never sees the transformed string.
  • Putting a real credential in a plain workflow variable instead of a secret. Fix: only ${{ secrets.NAME }} gets encrypted storage and automatic masking — a plain ${{ vars.NAME }} is stored and can appear in logs as plain text.
  • Leaving a secret at repository scope when it only belongs to production. Fix: scope it to the environment instead — an environment secret is invisible to any job that doesn't declare that exact environment:, shrinking the blast radius if a workflow is ever compromised.
  • Assuming GITHUB_TOKEN has broad access by default. Fix: it's short-lived and its permissions come entirely from the workflow's own permissions: key, expiring when the job ends — check that key rather than assuming any particular access level.
  • Letting a fork-triggered pull_request workflow reach a production environment's secrets. Fix: that's exactly what environment protection rules (required reviewers, wait timers) guard against — see §3 of Triggers & events for why a fork's pull_request needs less trust than a push from the repo itself.
Check yourself — a secret gets base64-encoded and then echoed to the log. Does GitHub's automatic masking catch it?

No. Masking only matches an exact occurrence of the secret's literal value — a transformed copy (base64-encoded, split into pieces, echoed indirectly) doesn't match, so it prints unmasked. Automatic masking is not a substitute for never printing or transforming a secret in the first place.

Check yourself — why would a deploy-to-production job sit behind an environment while deploy-to-staging might not?

Because an environment's protection rules (required reviewers, wait timers, environment-scoped secrets) let the pipeline encode the difference in blast radius directly — a mistake in production is far more costly than one in staging, so production gets a gate that staging doesn't need.

You can defend this when you can explain where a secret should be scoped for a given scenario, and name at least two things an environment's protection rules add that a bare repository secret does not.