GitHub Actions Secrets Empty on pull_request: Fixes

If your GitHub Actions secrets resolve to empty strings on a pull_request run, this is intentional, not a bug. GitHub withholds repository secrets from pull_request-triggered workflows that originate from forks, so a drive-by PR cannot exfiltrate your credentials. The fix is to move secret-dependent steps off pull_request entirely.

Why secrets are blank on pull_request

When someone opens a pull request from a fork, the workflow runs with code the maintainer has not reviewed. If that workflow could read your secrets, a one-line edit to the YAML, or a malicious test that prints os.environ["MY_SECRET"], would leak every deploy key and API token you have. To prevent this, GitHub strips all secrets from fork-triggered pull_request runs. The only credential available is GITHUB_TOKEN, and it is downgraded to read-only with no access to your other secrets.

The symptom is confusing because nothing errors loudly. Your ${{ secrets.DEPLOY_KEY }} reference simply expands to an empty string, and the downstream tool fails with a vague authentication error or a 401. There is no warning in the log that says "secrets were withheld." If a fork PR is the trigger, assume the secret is empty before you debug anything else.

It also affects same-repo PRs in some cases

A common misconception is that this only hits forks. It can also bite same-repository PRs. If a secret is scoped to a GitHub Environment, a job can only read it when that job declares environment: with the matching name. A pull_request job that omits the environment block sees nothing, even though the PR is internal and the secret exists. So "empty secret on a same-repo PR" is almost always an environment-scope problem rather than the fork rule.

The first checks before you change anything

Before re-architecting your workflows, rule out the boring causes. These account for a large share of "my secret is empty" reports that have nothing to do with the fork rule.

  • Name and case mismatch. Treat secret names as case-sensitive and match the YAML to the Settings UI exactly. GitHub conventionally stores names uppercase, but a typo like secrets.api_key against a stored API_KEY returns an empty string with no error.
  • Wrong scope. Run gh secret list at the repository scope and gh secret list --env production for an environment to confirm the secret actually lives where the job reads from.
  • Missing environment declaration. If the secret is an environment secret, the job needs an environment: block naming that environment.
  • Precedence collisions. If the same name exists at environment, repository, and organization levels, the environment value wins, then the repository value, then the organization value.

To confirm a value is present without leaking it, never echo the raw secret. Check its length instead, since GitHub masks the literal value in logs but not its character count.

- name: Check secret is populated
  run: echo "len=$(printf '%s' "$DEPLOY_KEY" | wc -c)"
  env:
    DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}

A length of 0 on a fork PR confirms the security rule is in effect; a non-zero length on a same-repo PR points you back at scope or naming.

The right fix: move secret steps off pull_request

For most projects the cleanest answer is to split the work. Run fast, secret-free validation on pull_request so every contributor, including forkers, gets immediate feedback. Put anything that needs credentials, like deploys, package publishing, or integration tests against a live service, on triggers that only trusted code reaches: push to a protected branch, or a manual workflow_dispatch.

name: CI
on:
  pull_request:
  push:
    branches: [main]

jobs:
  test:                      # runs on every PR, no secrets needed
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm test

  deploy:                    # only after merge to main
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - run: ./deploy.sh
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}

Because the deploy job runs on push after the PR is merged, the code is trusted and the secret is available. The PR feedback loop stays fast and safe. This pattern handles the vast majority of real cases and needs no clever workarounds.

Why pull_request_target is a trap

The first workaround people find online is pull_request_target. It runs in the context of the base branch, so it does have full access to secrets and a read-write GITHUB_TOKEN, even for fork PRs. The trap is that maintainers then add actions/checkout with the PR head ref to "test the changes," which checks out and runs untrusted fork code in a context that holds every secret. This is the classic "pwn request," and it has leaked real credentials from major open-source projects.

GitHub narrowed the blast radius in a change that took effect on December 8, 2025, so that pull_request_target always uses the default branch as its workflow source and reference, which blocks exploitation of stale or vulnerable workflow files on other branches. That is a partial mitigation only. If your workflow still checks out and executes PR-head code, an attacker can still run arbitrary commands with your secrets. The safe rule is absolute: never execute PR-controlled code in a job that can read secrets. If you must use pull_request_target, check out the base ref and use it only for labeling or commenting, never for building fork code.

The ok-to-test pattern for fork integration tests

When external contributors genuinely need secret-backed integration tests, use a human approval gate. The ok-to-test pattern works like this: a maintainer with write access comments a command such as /ok-to-test sha=<head-sha> on the fork PR, and only that comment triggers the privileged workflow that has secrets. Unprivileged unit tests still run automatically on every pull_request; the privileged job runs only after a human vouches for the diff.

Two cautions make this safe rather than theatrical. First, pin the run to a specific commit SHA, because of the time-of-check to time-of-use gap: an approval can be granted on a benign commit, then the attacker pushes malicious commits afterward. Binding approval to the reviewed SHA closes that window. Second, verify the commenter's permission dynamically against the GitHub API instead of using a hardcoded username allowlist, which rots as people gain or lose access. For maximum hardening, run privileged jobs from a workflow_run trigger that fires only after the unprivileged check completes, and scope test-only credentials to a dedicated environment so a leak cannot touch production keys.

If you are authoring or auditing these YAML files, our GitHub Actions workflow generator scaffolds correct trigger and job blocks so the env and secrets mappings land where you expect them. For the gh CLI commands above, keep the git cheat sheet handy.

Quick decision table

SituationDo this
Secret empty on a fork PRExpected. Move secret steps to push or workflow_dispatch.
Secret empty on a same-repo PRCheck environment scope, name, and case.
Need deploy or publishRun on push to a protected branch with an environment gate.
Need fork integration tests with secretsok-to-test pattern, SHA-pinned, dynamic actor check.
Tempted to use pull_request_targetAvoid for fork code; if used, check out base ref only.

The summary is simple: empty secrets on pull_request are GitHub protecting you. Keep untrusted PR code away from credentials, run secret work on trusted triggers, and reach for an explicit human approval gate only when fork tests truly require it.

Frequently Asked Questions

For pull requests from forks, GitHub intentionally withholds all repository secrets to prevent untrusted PR code from stealing them. Only a read-only GITHUB_TOKEN is provided. The reference expands to an empty string with no error.

It can. A same-repo PR sees secrets unless they are scoped to a GitHub Environment. If the secret is an environment secret, the job must declare a matching environment block or it reads nothing.

No, not by default. It runs with full secret access, so checking out and running fork code exposes every secret. Only use it for labeling or commenting, and check out the base ref rather than the PR head.

Use the ok-to-test pattern: a maintainer comments a command pinned to the PR's head SHA to trigger a privileged workflow. Verify the commenter's permissions dynamically and scope test-only secrets to a dedicated environment.