A secret is an encrypted value GitHub makes available to a workflow and attempts to mask in logs.
The word “attempts” is doing real work in that sentence, and this lesson spends most of its length on why.
The three scopes
Section titled “The three scopes”| Scope | Available to | Use for |
|---|---|---|
| Repository | Every workflow in the repository | Values one project needs |
| Environment | Jobs targeting that environment | Per-target credentials, with protection |
| Organisation | Repositories you select | Values many projects share |
gh secret set NPM_TOKENgh secret set DEPLOY_KEY --env productiongh secret set SHARED_TOKEN --org my-org --visibility selected --repos repo-a,repo-bgh secret listgh secret set prompts for the value, or reads standard input — which is how it stays out of your
shell history:
gh secret set NPM_TOKEN < token.txtprintf '%s' "$TOKEN" | gh secret set NPM_TOKENEnvironment secrets are the most useful of the three and the least used. A secret attached to a
production environment is available only to jobs declaring environment: production — and if that
environment has required reviewers, the secret is
unreachable until someone approves.
That combination — a credential that cannot be used without a human decision — is not achievable with repository secrets at all.
Using a secret
Section titled “Using a secret”jobs: deploy: runs-on: ubuntu-latest environment: production steps: - run: ./deploy.sh env: API_TOKEN: ${{ secrets.API_TOKEN }}Passing it through env: rather than interpolating it into the command is deliberate. In a run:
line the value is substituted as text before the shell parses it; through env: it is a variable the
shell reads.
# Worse — appears in the command- run: ./deploy.sh --token "${{ secrets.API_TOKEN }}"
# Better — the shell reads it from the environment- run: ./deploy.sh env: API_TOKEN: ${{ secrets.API_TOKEN }}The second also keeps the value out of the process list, where any other process on the runner could read it from the command line.
Masking, and what it does not do
Section titled “Masking, and what it does not do”GitHub replaces known secret values in log output with ***.
That is genuinely useful and it is a log filter, not a security boundary.
A secret being masked in logs does not make arbitrary handling of it safe.
What masking does not cover:
Transformed values. Base64-encode a secret, or print half of it, and the result does not match the known string. It is printed in full.
- run: echo "${{ secrets.TOKEN }}" | base64 # not maskedValues that leave the runner. A secret sent to an external service is gone. Masking applies to GitHub’s log output, not to what your code does.
Files. Writing a secret to a file the workflow later uploads as an artifact publishes it. Artifacts are not scanned.
The process list. A secret on a command line is visible to other processes on the runner.
Caches. A cached directory containing a credential file persists across runs and branches.
Very short or common values. A secret whose value is true or 1 would mask every occurrence of
that string, so short values are poorly protected in practice — and are a sign the value is not really
a secret.
Fork pull requests get nothing
Section titled “Fork pull requests get nothing”A workflow triggered by pull_request from a fork runs with no access to secrets and a
read-only token.
This is deliberate and it is the boundary that makes public CI safe: otherwise anyone could open a pull request whose workflow printed your credentials.
The visible consequence: a check needing a secret fails on external contributions and passes internally. That is the boundary working, not a bug. The options are to design the check so it does not need a secret, or to accept that it only runs after a maintainer takes the change.
pull_request_target does have secrets, which is exactly why it must never execute pull request
code — see Workflow Security.
GITHUB_TOKEN is a secret you did not create
Section titled “GITHUB_TOKEN is a secret you did not create”Every job receives one automatically:
- run: gh pr comment "$PR" --body "Build finished" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}Its properties are good defaults: it exists only for the job, is scoped to the workflow’s repository, and its permissions are configurable per workflow or job.
It cannot reach other repositories. Cross-repository automation needs a
GitHub App token, which
actions/create-github-app-token@v3 can mint from an App
ID and private key.
OIDC removes most cloud secrets
Section titled “OIDC removes most cloud secrets”The largest category of secrets in most pipelines is cloud credentials, and they are the category that should not exist.
# Before — a long-lived key, stored, exfiltratable- env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
# After — no stored credential at allpermissions: id-token: write contents: read
steps: - uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: arn:aws:iam::ACCOUNT_ID:role/github-actions aws-region: eu-west-1The second form stores nothing. The workflow requests a short-lived signed token from GitHub, AWS verifies its claims against a trust policy, and issues temporary credentials valid for minutes.
This is the highest-value change available in most pipelines, and it is the subject of GitHub Actions OIDC and the per-cloud lessons that follow it.
Rotation
Section titled “Rotation”Secrets that cannot expire will not be rotated unless something forces it.
gh secret listgh secret set API_TOKEN < new-token.txt # overwritegh secret delete OLD_TOKENgh secret list shows names and update times, never values — there is no endpoint that returns a
secret’s contents, by design.
That has a practical consequence: you cannot audit whether a stored secret is still correct, only when it last changed. Keep the authoritative copy in a secret manager and treat the GitHub secret as a deployed copy.
Rotation order matters. Create the new credential, update the secret, verify a run succeeds, then revoke the old one. Revoking first means an outage.
Secrets in reusable workflows
Section titled “Secrets in reusable workflows”A reusable workflow does not automatically see the caller’s secrets. They are passed explicitly, or inherited wholesale.
# Caller — explicitjobs: deploy: uses: ./.github/workflows/deploy.yml secrets: registry-token: ${{ secrets.REGISTRY_TOKEN }}
# Caller — everythingjobs: deploy: uses: ./.github/workflows/deploy.yml secrets: inherit# Called workflowon: workflow_call: secrets: registry-token: required: truesecrets: inherit is convenient and broad — the called workflow gets every secret the caller can see,
including ones it has no business with. For a reusable workflow in your own repository that is often
acceptable. For one shared across an organisation, naming the specific secrets is the better default,
because it documents exactly what the shared workflow can reach.
That difference matters more as reuse spreads. A reusable deployment workflow used by twenty
repositories, called with secrets: inherit, has access to twenty repositories’ full secret sets.
Auditing what exists
Section titled “Auditing what exists”Secrets accumulate. A periodic review is worth the ten minutes.
gh secret listgh secret list --env productiongh secret list --org my-org
gh api repos/OWNER/REPO/actions/secrets --jq '.secrets[] | [.name, .updated_at[0:10]] | @tsv'The updated_at column is the useful one. A secret last updated three years ago is either a credential
that has never been rotated or one nothing uses any more — and both are worth resolving.
What you cannot do is read the value back, by design. That means the audit answers “what exists and when did it change”, never “is it still correct”. Keeping the authoritative copy in a secret manager and treating the GitHub secret as a deployed copy is the way around that.
For anything you cannot account for, deletion is the right default. If something breaks, you have discovered what it was for, and re-adding a secret is cheap.
A checklist
Section titled “A checklist”Before a workflow that touches secrets goes anywhere near production:
- Is this a secret at all, or is it configuration that belongs in
vars? - Could OIDC eliminate it entirely?
- Is it scoped as narrowly as possible — environment rather than repository, selected repositories rather than all?
- Is it consumed through
env:rather than interpolated into a command? - Does anything transform it in a way that defeats masking?
- Could it end up in an artifact, a cache, or a job summary?
- Does the workflow run on
pull_request_targetorworkflow_run, where untrusted code must not execute? - Is there a rotation plan, and does anyone know the expiry?
Items 2 and 6 are the ones most often missed. The first eliminates whole categories of secret; the second is how a credential ends up published without anyone printing it.
Common mistakes
Section titled “Common mistakes”Assuming masking is protection. It is a log filter, defeated by any transformation.
Interpolating a secret into a command. Visible in the process list.
A secret in a variable. Plain text and unmasked.
Base64-encoding a secret in a log. Bypasses masking entirely.
Uploading a file containing a credential as an artifact. Not scanned.
Repository secrets where environment secrets belong. Loses the protection-rule gate.
Expecting fork pull requests to have secrets. They do not, deliberately.
Storing cloud keys at all. Use OIDC.
Secrets and the runner
Section titled “Secrets and the runner”Where a secret physically exists during a job is worth understanding, because it determines what can reach it.
A secret passed through env: becomes an environment variable in the step’s process. That means:
Any process the step starts inherits it. A build script, a test suite, a dependency’s install hook — all of them can read the environment.
A dependency with a postinstall script runs with those variables present. This is the concrete
mechanism behind most supply-chain concern in CI: the credential is not stolen from GitHub, it is read
from the environment of a process that had legitimate access.
It is visible in /proc on Linux to processes running as the same user.
Two mitigations follow:
Scope secrets to the steps that need them. A step-level env: block means the dependency install
step does not have the deployment credential in its environment at all.
- name: Install dependencies run: npm ci # no secrets in scope
- name: Deploy run: ./deploy.sh env: DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}That is a meaningful improvement over a job-level env: block, and it costs nothing.
Prefer short-lived credentials. An OIDC-derived token valid for fifteen minutes is a far smaller prize than a permanent one, even if something does read it.
Secret scanning and push protection
Section titled “Secret scanning and push protection”GitHub scans repositories for recognisable credential formats and can block pushes containing them.
That is genuinely valuable and it is a backstop, not a strategy. It recognises patterns it knows — provider tokens with distinctive shapes — and will not catch a database password, an internal hostname, a private key in an unusual format, or a credential your organisation issues itself.
For workflows specifically, the risk is less “a secret in the repository” and more “a secret that reaches a log, an artifact or a cache”. None of those are scanned.
Deciding what needs to be a secret
Section titled “Deciding what needs to be a secret”A short decision procedure, since over-classifying is its own problem — a value stored as a secret is one nobody can see, which makes debugging harder for no benefit.
| Value | Secret? |
|---|---|
| API token, password, private key | Yes |
| Cloud access key | Yes — and it should not exist; use OIDC |
| Webhook signing secret | Yes |
| Database connection string with credentials | Yes |
| Database hostname without credentials | Usually a variable |
| Cloud region, account alias | Variable |
| Internal service URL | Judgement; often a variable |
| Feature flag, log level | Variable |
| An artifact’s name or digest | Neither — an output |
The test remains: would disclosure matter? If the honest answer is “it would be mildly embarrassing”, it is a variable. If it is “someone could use this”, it is a secret.
Exercise
Section titled “Exercise”- Set a repository secret with
gh secret set, reading from a file rather than typing it. - Use it via
env:in a step and print a message that does not include it. - Print it directly and observe the masking.
- Print it base64-encoded and observe that masking does not apply.
- Create an environment with a required reviewer, move the secret there, and confirm the job waits.
- Delete both secrets.
Step 4 is the one worth doing. Seeing a secret appear in a log despite masking is far more convincing than being told that masking is shallow.
Managing secrets from the CLI
Section titled “Managing secrets from the CLI”The full command set, since the interface is slower for anything beyond a single value:
gh secret listgh secret list --env productiongh secret list --org my-org
gh secret set NAME # promptsgh secret set NAME < file # from a fileprintf '%s' "$VALUE" | gh secret set NAME # from a variable, no historygh secret set NAME --env productiongh secret set NAME --org my-org --visibility selected --repos a,b
gh secret delete NAMEgh secret delete NAME --env productionThe printf form matters: echo "$VALUE" | gh secret set appends a trailing newline, which becomes
part of the secret. A token with a trailing newline fails authentication in ways that are genuinely
hard to diagnose, because the value looks right everywhere you can see it.
For bulk setup — a new repository needing the same secrets as an existing one — there is no copy command, because values cannot be read back. That constraint is deliberate, and it is the argument for keeping the authoritative copies in a secret manager and scripting the population from there.
What you learned
Section titled “What you learned”- Secrets exist at repository, environment and organisation scope; environment secrets can be gated by approval.
- Masking is a log filter — transformation, files, artifacts, caches and the process list all bypass it.
- A secret that reaches a log must be rotated; deleting the run does not help.
- Fork pull requests get no secrets and a read-only token, deliberately.
GITHUB_TOKENis provided per job and cannot reach other repositories.- OIDC removes the largest category of stored secrets entirely.
- Secret values cannot be read back, so the authoritative copy belongs elsewhere.
Organisation secrets and selected repositories
Section titled “Organisation secrets and selected repositories”An organisation secret is defined once and made available to repositories you choose.
gh secret set SHARED_NPM_TOKEN --org my-org --visibility allgh secret set DEPLOY_KEY --org my-org --visibility privategh secret set NARROW_TOKEN --org my-org --visibility selected --repos repo-a,repo-bgh secret list --org my-orgThe three visibilities are all, private (every private repository), and selected. selected is
the one to prefer — an organisation secret visible to every repository is available to every workflow
anyone in the organisation writes, including new repositories nobody has reviewed.
Scopes resolve most-specific-first, so a repository secret of the same name shadows the organisation
one. That is useful for overriding centrally, and it is also a way to be surprised: a workflow reading
secrets.API_TOKEN may be getting a different value than the organisation administrator expects.
gh secret list # repositorygh secret list --org my-org # organisationgh secret list --env production # environmentChecking all three is the only reliable way to know which value a workflow will actually see.
Responding to a leaked secret
Section titled “Responding to a leaked secret”The order matters, and the first step is the one people delay while investigating.
- Revoke the credential at its source — the cloud provider, the registry, the API. Not the GitHub secret; the underlying credential. A revoked token is inert regardless of who has it.
- Issue a replacement and update the secret.
- Verify a run succeeds with the new value.
- Assess the exposure window — how long was it in a log, and who could read it.
- Delete the run if the log is still retained. Damage limitation, not remediation.
- Fix the leak path — the step that printed it, the artifact that contained it.
Step 1 before step 4 is the important ordering. Investigating first leaves a live credential exposed while you work out how bad it is.
What does not work: deleting the workflow run, rewriting history, or making the repository private. None of those invalidate a credential that has already been read, and treating them as remediation is how tokens stay live for months after their exposure was noticed.
Secrets that should not be secrets
Section titled “Secrets that should not be secrets”Two categories are worth moving out of secret storage entirely.
Cloud credentials — replaced by OIDC, covered above.
Anything a token can be scoped down to. A GITHUB_TOKEN with declared permissions is better than
a personal access token stored as a secret, because it is issued per job, expires with it, and belongs
to nobody. If a workflow uses a stored PAT to act on its own repository, GITHUB_TOKEN is almost
certainly sufficient — see
Least-Privilege Permissions.
For cross-repository work where GITHUB_TOKEN genuinely cannot reach, a
GitHub App token minted per run is better than a stored PAT for the same
reasons: scoped, short-lived, and owned by the app rather than a person who might leave.
- uses: actions/create-github-app-token@v3 id: app-token with: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} owner: ${{ github.repository_owner }}
- run: gh issue list --repo other-org/other-repo env: GH_TOKEN: ${{ steps.app-token.outputs.token }}Note that this still requires one stored secret — the App’s private key — but it is a single credential producing short-lived scoped tokens, rather than a long-lived token per integration.
The order of preference
Section titled “The order of preference”For any credential a workflow needs, work down this list and stop at the first that applies:
GITHUB_TOKENwith declared permissions — automatic, scoped, expires with the job.- OIDC — no stored credential at all, for anything cloud.
- A GitHub App token minted per run — for cross-repository work, from one stored private key.
- An environment secret — gated by protection rules, scoped to one target.
- A repository secret — when nothing narrower fits.
- An organisation secret with
selectedvisibility — for genuinely shared values.
Most pipelines that store cloud keys as repository secrets could be at step 2, and most that store a personal access token could be at step 1 or 3. Working down the list deliberately usually removes more secrets than it keeps.
Summary
Section titled “Summary”Masking is a log filter, not a boundary. A secret that reaches a log must be rotated, and the largest category of secrets — cloud credentials — should not exist at all once OIDC is in place.