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_TOKENis auto-issued and short-lived; its permissions come from the workflow's ownpermissions: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
pushand apull_requestfrom 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_TOKENhas broad access by default. Fix: it's short-lived and its permissions come entirely from the workflow's ownpermissions:key, expiring when the job ends — check that key rather than assuming any particular access level. - Letting a fork-triggered
pull_requestworkflow 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'spull_requestneeds less trust than apushfrom 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.