GitHub Actions secrets empty in fork pull requests explained
GitHub Actions secrets empty in fork pull requests is a deliberate rule rather than a failure, and it produces no message at the moment it applies. Every reference simply becomes an empty string, and the first thing to complain is whichever tool was handed one.

What this error means
A workflow is green on every push and red on every pull request opened from a fork, with no change to the file in between. The failing step is different from repository to repository, because the error belongs to the program that received the empty value rather than to Actions. Several steps often fail at once for the same reason, and the log never explains why the value was empty, because nothing observed it being emptied.
There is no error, which is why this is hard to find
An expression referring to a secret that is unavailable does not fail. It evaluates to an empty string, the step starts normally, and the job carries on until something objects. That is the whole mechanism, and it is why searching the log for the secret's name returns nothing: the name was replaced before anything was written down, and the value would have been masked even if it had been present.
The rule itself is short and GitHub states it plainly: "With the exception of GITHUB_TOKEN, secrets are not passed to the runner when a workflow is triggered from a forked repository." The exception matters. GITHUB_TOKEN is still there, in read-only form, which is why some steps in the same job keep working and the failure looks selective.
Common causes
The workflow runs on pull_request and the head repository is a fork
The rule in its plain form. Every repository, organization and environment secret is withheld, all at once, for the whole run. Nothing in the file needs to be wrong and usually nothing is.
A step needs a credential that the build genuinely does not
Coverage upload, a preview deployment, a container push or a comment posted through a third-party service. These are the steps that fail, and in most repositories none of them is part of validating the contributor's change.
The workflow was switched to pull_request_target without understanding it
This restores secrets by running the workflow file from the base branch, and it is dangerous precisely when it is useful, because checking out the fork's code and running it hands that code the secrets. Recent policy has also begun blocking this trigger by default on public repositories.
A guard on the secret was added and the run stopped starting
Worth listing because it is a common second failure. The guard was written as an if on the secrets context, which is not accepted there, so the run is now rejected at parse time and no job is created at all.
How to fix it
Separate the work that needs credentials from the work that does not
- Decide which steps exist to validate the contributor's change; those should run on fork pull requests.
- Move the steps that need a secret into a job or a workflow that fork pull requests do not reach.
- Leave the contributor with a green check that means something rather than a red one they cannot act on.
Guard through env, not through secrets
When one workflow must serve both cases, assign the secret to an environment variable where the context is accepted, then branch on the variable. The value stays masked and the run parses.
jobs:
test:
runs-on: ubuntu-latest
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
steps:
- uses: actions/checkout@v5
- run: npm test
- name: Upload coverage
if: env.CODECOV_TOKEN != ''
run: ./scripts/upload-coverage.shUse a second workflow for the privileged half
Let the pull request run build and test with no secrets and upload an artifact, then have a separate workflow triggered by that run do the privileged work. The second workflow runs from the base branch with access to secrets, and the artifact it consumes should be treated as untrusted data because a fork produced it.
Treat pull_request_target as a last resort
It restores secrets by running the base branch's workflow file, so it is only safe while the fork's code is never executed. Never check the pull request head into the working directory and then build it. On public repositories, check whether a policy now blocks the trigger before relying on it.
The guard that looks obvious does not parse
The natural response is to skip the step when the secret is missing, and the natural place to put that is an if. It does not work, and it does not fail at runtime either: the run is rejected before any job starts, with a message about an unrecognized name. The secrets context is not among the names accepted in an if at either level.
| Where you want to test the secret | Accepted there | What to write instead |
|---|---|---|
jobs.<job_id>.if | No | Set it in the job's env, then test env.NAME |
jobs.<job_id>.steps.if | No | Set it in the job's or step's env, then test env.NAME |
jobs.<job_id>.steps.env | Yes | Assign it here, which is what makes the test above possible |
jobs.<job_id>.steps.with | Yes | Pass it to an action here as normal |
What the rule protects, and what it costs
A pull request from a fork runs code that someone outside the repository wrote. If secrets were available to it, a one-line change to a build script would print them, and no review process catches that reliably. Withholding them is the only measure that does not depend on a human noticing.
The cost lands on honest contributors, who see a red check they cannot fix and often cannot diagnose. That is worth designing around rather than working around: a job that builds and tests without credentials gives a contributor a useful signal, and the steps that need a secret belong somewhere a fork cannot reach.
Why there is no recorded run on this page
Producing this would require a second account to open a pull request from a fork against a repository of ours, which is a person rather than a runner, and what the resulting log would show is the absence of a value. There would be nothing in it to quote: no annotation, no warning, and a masked variable that is indistinguishable from an unset one. The rule is published as a single sentence, the context availability is published as a table, and both are better evidence than a log of nothing happening.
How to prevent it
- Design the pull request workflow to need no secrets, so a fork contribution is a first-class case rather than an exception.
- Write every secret test against
env, so the habit never produces a parse error. - Keep privileged steps in a workflow whose trigger a fork cannot reach.
- Tell contributors in the repository what a failing credentialed check means, since they cannot fix it.
Frequently asked questions
Why is there no warning that the secret was unavailable?
Why do some steps still work on a fork pull request?
GITHUB_TOKEN is the documented exception and is still provided, in read-only form. Steps that authenticate with it keep working for reads, while steps that need one of your own secrets do not. That mixture is what makes the failure look selective rather than systematic.Can I check whether a secret is set before using it?
secrets context is not available in an if at job or step level, so testing it there rejects the whole run at parse time. Assign the secret to env first, where the context is accepted, and test the environment variable in the condition.