GitHub Actions Secrets and Variables
How to store credentials for a workflow without leaking them. Repository, environment and organisation scopes, log masking, and why secrets are unavailable on fork pull requests.
A workflow that deploys anything needs credentials, and the mechanism you choose decides how much damage a mistake does. GitHub Actions offers three scopes for configuration and two kinds of value, and the differences matter more than the syntax does.
Verified against GitHub Actions as of September 2026.
Two Kinds of Value
Variables are configuration that is not sensitive: an AWS region, a log level, an image name. They are readable in the settings UI, appear in logs in full, and are available as the vars context.
Secrets are values that grant access. They are write-only in the UI, meaning nobody including you can read one back after saving. They are masked in logs, and available as the secrets context.
The rule that keeps this simple: if leaking the value would require you to rotate something, it is a secret. Everything else is a variable, and making it a variable is actively useful because you can see what it is set to when debugging.
Three Scopes
Repository scope applies to every workflow in one repository. This is the default place to put things.
Organisation scope applies across repositories, with an access policy choosing which ones. Useful for a shared registry credential that a dozen services all need, and the reason to prefer it is rotation: one update instead of twelve.
Environment scope applies only to jobs that declare that environment, and this is the scope worth understanding properly, because it is the only one that can require human approval.
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- run: ./deploy.sh
env:
API_TOKEN: ${{ secrets.API_TOKEN }}
With a production environment configured to require reviewers, this job pauses and waits for an approval before it starts. The secret is not available to the job until that approval happens. A workflow on a branch that the environment's protection rules do not allow cannot read the secret at all.
This is the difference that matters: a repository secret is readable by any workflow in the repository, including one added in a pull request by someone with write access. An environment secret with branch protection is not.
Using Them
env:
# Workflow-level, plain text, visible in logs.
AWS_REGION: eu-west-1
jobs:
build:
runs-on: ubuntu-latest
env:
# Job-level, from a repository or organisation variable.
IMAGE_NAME: ${{ vars.IMAGE_NAME }}
steps:
- uses: actions/checkout@v4
- name: Publish
env:
# Step-level, from a secret. Prefer this to inline interpolation.
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish
Note the pattern of passing secrets through env: on the step rather than interpolating them into the run: script. The reason is concrete: ${{ }} interpolation happens before the shell sees the line, so the secret's literal text is substituted into the command. A secret containing a quote, a backtick or a $ can then break the command or, worse, be interpreted by the shell. Passing through env: hands the value to the process as an environment variable and the shell never parses it.
This is the same class of problem as SQL injection, and it applies to any untrusted or unpredictable value, including github.event.pull_request.title.
Masking, and Its Limits
GitHub scans log output for the exact text of every secret and replaces matches with ***. This works and it is not a substitute for care, because it is a literal string match.
It does not catch a secret that has been transformed. If your workflow base64-encodes a token, or prints only the first eight characters, or embeds it in a URL that then gets percent-encoded, the output no longer matches the stored value and nothing is masked.
It also does not catch structured secrets well. Storing a whole JSON service-account key as one secret means the individual field values inside it are not separately registered, so a step that parses the JSON and echoes one field prints it in clear text.
You can register a derived value at runtime:
- run: |
DERIVED="$(compute-something)"
echo "::add-mask::$DERIVED"
echo "derived=$DERIVED" >> "$GITHUB_ENV"
::add-mask:: tells the runner to treat that string as a secret for the remainder of the job. Any value you derive from a secret and intend to log should go through it.
Fork Pull Requests
This is where most security incidents in CI come from, and the behaviour is deliberate.
On a pull_request run from a fork, secrets are not available and the GITHUB_TOKEN is read-only. If secrets were available, anyone on the internet could open a pull request whose workflow prints them.
The consequence is that a workflow needing credentials cannot run on external contributions, and the correct response is to split the workflow: a pull_request workflow that lints, builds and tests with no credentials, and a separate push workflow on the default branch that does anything privileged.
The dangerous escape hatch is pull_request_target, which runs in the context of the base repository and therefore does have secrets. It is safe only if it does not check out or execute the pull request's code. Doing this:
# Do not do this.
on: pull_request_target
jobs:
build:
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: npm ci && npm run build
hands a stranger's code your secrets, because npm ci runs whatever install scripts that fork's package.json declares. If you need pull_request_target, use it only to read metadata such as labels, and never to run the contributor's code.
What Not to Store as a Secret
Long-lived cloud access keys. If you are putting AWS_SECRET_ACCESS_KEY into GitHub, there is a better option: OpenID Connect lets a workflow exchange a short-lived GitHub-signed token for temporary cloud credentials, with no stored key to leak or rotate. That is covered in the deploying to AWS with OIDC lesson, and it is the single biggest security improvement available to most pipelines.
If a workflow of yours is failing on role assumption, the OIDC could not assume role page covers the trust-policy mistakes that cause it.
Frequently Asked Questions
Can I read a secret back after saving it?
No. Secrets are write-only through the UI and the API. If you have lost the value, rotate it at the source and store the new one. This is why storing credentials in a password manager as the source of truth, with GitHub as a copy, saves real time.
Are secrets available to workflows on any branch?
Repository and organisation secrets are available to any workflow run in that repository, including from a branch. Environment secrets are restricted by the environment's deployment branch rules, which is the mechanism to use when a secret must only be reachable from main.
Why is my secret empty in the workflow?
Most often the run is a pull_request from a fork, where secrets are deliberately withheld. Otherwise check the name for case: secret names are case-insensitive when stored but secrets.MY_TOKEN and secrets.my_token both resolve, whereas a typo silently produces an empty string rather than an error. Echoing ${{ secrets.NAME != '' }} tells you whether it is set without revealing it.
Can I use secrets in the if condition of a job?
Not directly. The secrets context is unavailable in job-level if. The workaround is a preceding job that reads the secret and emits a boolean job output, which the dependent job's if can then use.
Should each environment have its own secret, or one secret with the environment in the name?
Use environment scope with the same secret name in each. A single API_TOKEN defined separately under staging and production keeps the workflow file identical for both and means a deploy job cannot accidentally reach the wrong one.