Skip to content

GitHub Actions Secrets: Storage, Masking and Safe Handling

Lesson 9 of 11Beginner13 min readGitHub Actions & CI/CD · Actions FundamentalsVerified: gh 2.98.0 and GitHub Actions secrets documentation, August 2026

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.

ScopeAvailable toUse for
RepositoryEvery workflow in the repositoryValues one project needs
EnvironmentJobs targeting that environmentPer-target credentials, with protection
OrganisationRepositories you selectValues many projects share
Terminal window
gh secret set NPM_TOKEN
gh secret set DEPLOY_KEY --env production
gh secret set SHARED_TOKEN --org my-org --visibility selected --repos repo-a,repo-b
gh secret list

gh secret set prompts for the value, or reads standard input — which is how it stays out of your shell history:

Terminal window
gh secret set NPM_TOKEN < token.txt
printf '%s' "$TOKEN" | gh secret set NPM_TOKEN

Environment 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.

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.

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 masked

Values 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.

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.

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 all
permissions:
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-1

The 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.

Secrets that cannot expire will not be rotated unless something forces it.

Terminal window
gh secret list
gh secret set API_TOKEN < new-token.txt # overwrite
gh secret delete OLD_TOKEN

gh 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.

A reusable workflow does not automatically see the caller’s secrets. They are passed explicitly, or inherited wholesale.

# Caller — explicit
jobs:
deploy:
uses: ./.github/workflows/deploy.yml
secrets:
registry-token: ${{ secrets.REGISTRY_TOKEN }}
# Caller — everything
jobs:
deploy:
uses: ./.github/workflows/deploy.yml
secrets: inherit
# Called workflow
on:
workflow_call:
secrets:
registry-token:
required: true

secrets: 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.

Secrets accumulate. A periodic review is worth the ten minutes.

Terminal window
gh secret list
gh secret list --env production
gh 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.

Before a workflow that touches secrets goes anywhere near production:

  1. Is this a secret at all, or is it configuration that belongs in vars?
  2. Could OIDC eliminate it entirely?
  3. Is it scoped as narrowly as possible — environment rather than repository, selected repositories rather than all?
  4. Is it consumed through env: rather than interpolated into a command?
  5. Does anything transform it in a way that defeats masking?
  6. Could it end up in an artifact, a cache, or a job summary?
  7. Does the workflow run on pull_request_target or workflow_run, where untrusted code must not execute?
  8. 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.

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.

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.

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.

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.

ValueSecret?
API token, password, private keyYes
Cloud access keyYes — and it should not exist; use OIDC
Webhook signing secretYes
Database connection string with credentialsYes
Database hostname without credentialsUsually a variable
Cloud region, account aliasVariable
Internal service URLJudgement; often a variable
Feature flag, log levelVariable
An artifact’s name or digestNeither — 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.

  1. Set a repository secret with gh secret set, reading from a file rather than typing it.
  2. Use it via env: in a step and print a message that does not include it.
  3. Print it directly and observe the masking.
  4. Print it base64-encoded and observe that masking does not apply.
  5. Create an environment with a required reviewer, move the secret there, and confirm the job waits.
  6. 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.

The full command set, since the interface is slower for anything beyond a single value:

Terminal window
gh secret list
gh secret list --env production
gh secret list --org my-org
gh secret set NAME # prompts
gh secret set NAME < file # from a file
printf '%s' "$VALUE" | gh secret set NAME # from a variable, no history
gh secret set NAME --env production
gh secret set NAME --org my-org --visibility selected --repos a,b
gh secret delete NAME
gh secret delete NAME --env production

The 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.

  • 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_TOKEN is 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.

Terminal window
gh secret set SHARED_NPM_TOKEN --org my-org --visibility all
gh secret set DEPLOY_KEY --org my-org --visibility private
gh secret set NARROW_TOKEN --org my-org --visibility selected --repos repo-a,repo-b
gh secret list --org my-org

The 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.

Terminal window
gh secret list # repository
gh secret list --org my-org # organisation
gh secret list --env production # environment

Checking all three is the only reliable way to know which value a workflow will actually see.

The order matters, and the first step is the one people delay while investigating.

  1. 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.
  2. Issue a replacement and update the secret.
  3. Verify a run succeeds with the new value.
  4. Assess the exposure window — how long was it in a log, and who could read it.
  5. Delete the run if the log is still retained. Damage limitation, not remediation.
  6. 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.

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.

For any credential a workflow needs, work down this list and stop at the first that applies:

  1. GITHUB_TOKEN with declared permissions — automatic, scoped, expires with the job.
  2. OIDC — no stored credential at all, for anything cloud.
  3. A GitHub App token minted per run — for cross-repository work, from one stored private key.
  4. An environment secret — gated by protection rules, scoped to one target.
  5. A repository secret — when nothing narrower fits.
  6. An organisation secret with selected visibility — 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.

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.

GitHub Actions Security ChecklistAudit your workflows against the failure modes that actually cause incidents. Free and complete.

The CI starter template — least-privilege permissions, pinned actions, correct checkout — is in the Professional Toolkit.