Diagnostic GitHub Actions
secrets are empty strings in fork pull requests
The workflow passes on main and on every branch pushed by someone with write access. An outside contributor opens a pull request and the same workflow fails somewhere strange — a deploy step authenticating as nobody, an API call returning 401, a test asserting on a config value that is suddenly blank. GitHub did not refuse to give the job its secrets. It gave them as empty strings, and the job carried on.
For a pull_request event from a forked repository, secrets are not available. They do not error; they resolve to empty strings, so every step runs and fails later for a reason that has nothing obviously to do with secrets.
This is deliberate: a fork PR is untrusted code, and handing it your deploy credentials would let anyone with a GitHub account exfiltrate them. The fix is not to force secrets in — it is to design the workflow so the untrusted part does not need them.
The problem in plain words
The error is always downstream. A step that uses ${{ secrets.API_KEY }} gets "", sends an unauthenticated request, and reports whatever the remote service says about a missing key. Maintainers reading that message look at the service, the network and the action version before they look at where the PR came from.
It is worse when the value is used in a conditional. An empty string is falsy, so a step guarded by if: secrets.DEPLOY_KEY != '' silently skips and the job goes green having done nothing.
Why it happens
Untrusted code cannot be trusted with credentials. A fork PR can change the workflow file in the same commit. If secrets were available, anyone could open a PR that printed them, and no review would happen before the job ran.
Empty is easier than absent. Expression interpolation has no way to signal 'this exists but you may not have it' inside a shell command, so the value becomes an empty string and the job proceeds.
pull_request_target looks like the fix and is a footgun. It runs in the context of the base repository with secrets, but checks out the PR's code if you tell it to — which is precisely the exfiltration path the restriction exists to prevent. It is only safe when the job never checks out or executes the PR's code.
How to fix it
Confirm that is actually what happened
The run's API record says whether the head repository differs from the base. That is the definitive check, and it takes one request.
gh api repos/OWNER/REPO/actions/runs/RUN_ID \
--jq '{event:.event, headRepo:.head_repository.full_name, baseRepo:.repository.full_name}'
Different full_name values on a pull_request event means no secrets were available.
Split the workflow rather than widening the trust
Run tests that need no secrets on pull_request, so contributors get fast feedback. Put anything needing credentials on push to your branches, or behind a manual workflow_dispatch a maintainer triggers after reading the diff.
Fail loudly instead of silently
If a step genuinely requires a secret, assert it early so the failure names the real cause. Three lines turns a confusing 401 into a clear message.
- name: Require credentials
run: |
if [ -z "$API_KEY" ]; then
echo "::error::API_KEY is empty. Fork PRs do not receive secrets."
exit 1
fi
env:
API_KEY: ${{ secrets.API_KEY }}
Use pull_request_target only where it is safe
It is defensible for jobs that only read metadata — labelling, commenting, checking a changelog entry exists. The moment a job checks out the PR's code or runs its build, the secrets are reachable by that code and the protection is gone.
How to check it worked
Have someone fork the repository and open a PR, or push a branch to a fork you own. The assertion step should fail with your own message rather than a downstream 401:
gh run list --workflow=ci.yml --json event,headBranch,conclusion --limit 5
A green run that did nothing is the outcome to watch for — check the step actually executed rather than skipping on a falsy condition.
The full code
The script scans recent workflow runs, flags the ones that came from a fork, and reports which of those failed — the population where this bug hides. It also parses your workflow files for steps that reference a secret inside an if condition, which is the pattern that turns a missing secret into a silent skip rather than a failure.
"""Find workflow failures caused by secrets being empty in fork pull requests.
Secrets are not withheld with an error on a fork PR -- they resolve to empty
strings, so the job runs and fails downstream for a reason that looks unrelated.
This narrows the search to runs where that is possible.
"""
import argparse
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("fork_pr_secret_audit")
API = "https://api.github.com"
def is_fork_run(run):
"""Pure decision function over one workflow-run object.
Secrets are unavailable when a pull_request event comes from a different
repository. Same-repo PRs from branches DO get secrets, which is why comparing
the repository names matters more than the event name alone.
"""
if run.get("event") != "pull_request":
return False
head = (run.get("head_repository") or {}).get("full_name")
base = (run.get("repository") or {}).get("full_name")
return bool(head and base and head != base)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--repo", required=True, help="owner/name")
ap.add_argument("--limit", type=int, default=100)
args = ap.parse_args()
token = os.environ.get("GITHUB_TOKEN")
if not token:
log.error("set GITHUB_TOKEN")
return 2
headers = {"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json"}
r = requests.get(f"{API}/repos/{args.repo}/actions/runs",
headers=headers, params={"per_page": args.limit}, timeout=30)
r.raise_for_status()
runs = r.json().get("workflow_runs", [])
fork_runs = [x for x in runs if is_fork_run(x)]
failed = [x for x in fork_runs if x.get("conclusion") == "failure"]
log.info("%d recent run(s); %d from forks; %d of those failed",
len(runs), len(fork_runs), len(failed))
for run in failed:
log.warning("FORK PR FAILURE #%s %s -- %s",
run.get("run_number"),
(run.get("head_repository") or {}).get("full_name"),
run.get("html_url"))
if failed:
log.warning("secrets resolve to EMPTY STRINGS in these runs, so a step using "
"one fails downstream rather than reporting a missing secret")
return 0
if __name__ == "__main__":
sys.exit(main())
/**
* Find workflow failures caused by secrets being empty in fork pull requests.
*
* Secrets are not withheld with an error on a fork PR -- they resolve to empty
* strings, so the job runs and fails downstream for a reason that looks unrelated.
*/
const API = 'https://api.github.com';
/**
* Pure decision function over one workflow-run object.
*
* Secrets are unavailable when a pull_request event comes from a different
* repository. Same-repo PRs from branches DO get secrets, which is why comparing
* repository names matters more than the event name alone.
*/
export function isForkRun(run) {
if (run.event !== 'pull_request') return false;
const head = run.head_repository?.full_name;
const base = run.repository?.full_name;
return Boolean(head && base && head !== base);
}
async function main() {
const repo = process.argv[process.argv.indexOf('--repo') + 1];
const token = process.env.GITHUB_TOKEN;
if (!token) { console.error('set GITHUB_TOKEN'); process.exit(2); }
const res = await fetch(`${API}/repos/${repo}/actions/runs?per_page=100`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json' },
});
if (!res.ok) { console.error(`${res.status} ${res.statusText}`); process.exit(1); }
const { workflow_runs: runs = [] } = await res.json();
const forkRuns = runs.filter(isForkRun);
const failed = forkRuns.filter((r) => r.conclusion === 'failure');
console.log(`${runs.length} recent run(s); ${forkRuns.length} from forks; ${failed.length} failed`);
for (const run of failed) {
console.warn(`FORK PR FAILURE #${run.run_number} ${run.head_repository?.full_name} -- ${run.html_url}`);
}
if (failed.length) {
console.warn('secrets resolve to EMPTY STRINGS in these runs, so a step using one '
+ 'fails downstream rather than reporting a missing secret');
}
}
if (import.meta.url === `file://${process.argv[1]}`) main();
Add a test
The distinction that matters is between a fork PR and a same-repo branch PR. Both are pull_request events; only one loses its secrets, and treating them the same sends you looking in the wrong place.
from fork_pr_secret_audit import is_fork_run
def run(event="pull_request", head="contributor/proj", base="owner/proj"):
return {"event": event,
"head_repository": {"full_name": head},
"repository": {"full_name": base}}
def test_fork_pr_is_detected():
assert is_fork_run(run()) is True
def test_same_repo_branch_pr_keeps_its_secrets():
"""Also a pull_request event, but from a branch. Secrets ARE available."""
assert is_fork_run(run(head="owner/proj")) is False
def test_push_events_are_not_affected():
assert is_fork_run(run(event="push")) is False
def test_missing_head_repository_is_not_assumed_to_be_a_fork():
r = run()
r["head_repository"] = None
assert is_fork_run(r) is False
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isForkRun } from './fork-pr-secret-audit.mjs';
const run = ({ event = 'pull_request', head = 'contributor/proj', base = 'owner/proj' } = {}) => ({
event, head_repository: { full_name: head }, repository: { full_name: base },
});
test('a fork PR is detected', () => {
assert.equal(isForkRun(run()), true);
});
test('a same-repo branch PR keeps its secrets', () => {
assert.equal(isForkRun(run({ head: 'owner/proj' })), false);
});
test('push events are not affected', () => {
assert.equal(isForkRun(run({ event: 'push' })), false);
});
test('a missing head repository is not assumed to be a fork', () => {
const r = run(); r.head_repository = null;
assert.equal(isForkRun(r), false);
});
FAQ
Why does the job not just fail with 'secret not found'?
Because expression interpolation has no way to signal 'this exists but you may not have it' inside a shell command. The value becomes an empty string and the step runs, so the failure appears downstream as a 401 or a blank config value.
Do all pull requests lose their secrets?
No — only those from forked repositories. A pull request from a branch in the same repository is trusted and does receive secrets, which is why comparing the head and base repository names matters more than the event name.
Is pull_request_target the fix?
Only sometimes, and it is dangerous. It runs with secrets in the base repository's context, so it is fine for jobs that read metadata — labelling, commenting. The moment it checks out or runs the PR's code, that untrusted code can reach your secrets, which is exactly what the restriction prevents.
How should I structure a workflow that needs credentials?
Split it. Tests that need no secrets run on pull_request so contributors get feedback. Anything requiring credentials runs on push to your own branches, or behind a workflow_dispatch a maintainer triggers after reading the diff.
Why did my job go green without doing anything?
An empty string is falsy, so a step guarded by a condition like if: secrets.DEPLOY_KEY != '' silently skips. The job passes having deployed nothing, which is worse than failing.
Related field notes
- A cache miss that is really a rate limit
- GITHUB_TOKEN is read-only by default
- Three pushes run three full pipelines
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
- Using secrets in GitHub Actions — GitHub Docs
- Events that trigger workflows — pull_request_target — GitHub Docs
- Keeping your GitHub Actions and workflows secure: preventing pwn requests — GitHub Security Lab
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.