Dev.to Security 🔐 Cybersecurity 👁 0 📖 7 min read

CI Secrets Are Empty in Fork Pull Requests: The Safe Patterns That Actually Work

If a job that works on your branches fails on a contributor's fork PR with an empty credential, nothing is misconfigured: GitHub Actions deliberately withholds secrets from pull_request runs triggered by a fork, and scop

If a job that works on your branches fails on a contributor's fork PR with an empty credential, nothing is misconfigured: GitHub Actions deliberately withholds secrets from pull_request runs triggered by a fork, and scopes GITHUB_TOKEN to read-only. The fix is not to find the setting that turns secrets back on — there isn't one. You either make the PR job work without secrets, or move the secret-dependent part into a second workflow that runs in your repository's context after the untrusted code has already been built and tested.

What the failure actually looks like

The symptom is rarely the word "secret." The secret interpolates to an empty string, and whatever consumes it fails with its own vocabulary:

Error: Input required and not supplied: password
Error: Could not load credentials from any providers
HTTP 401 Unauthorized

The giveaway is the comparison: identical workflow, identical commit, green when the branch lives in your repo, red the moment the same commit arrives from contributor/your-repo. A one-line probe settles it, and it is safe because it never prints the value:

- name: Probe secret availability
  run: |
    echo "has NPM_TOKEN: ${{ secrets.NPM_TOKEN != '' }}"
    echo "event: ${{ github.event_name }}"
    echo "fork: ${{ github.event.pull_request.head.repo.fork }}"

On a fork PR you get has NPM_TOKEN: false and fork: true. That is the whole bug.

Takeaway: a fork PR failing on an empty credential is GitHub working as designed, not a broken configuration.

Why does GitHub withhold secrets from fork pull requests?

Because a pull_request run executes code the author of the PR controls. Anyone with a GitHub account can open a PR against a public repository. If that run carried your deploy keys, a PR that edits a test file, a package.json prepare script, or a Makefile target could exfiltrate every secret in the repository in a single commit, and the attacker would never need write access.

So the boundary is drawn at the only place it can be: untrusted code runs with no secrets and a read-only token. The two triggers that do carry secrets — pull_request_target and workflow_run — run in the base repository's context and, by default, check out your code, not the PR's. Everything that follows is about keeping that distinction intact while still getting useful signal.

Takeaway: the trust boundary is "whose code is executing," not "which repo the branch is in."

Which pattern should you use?

Approach Secrets available Executes PR code Appears as a PR check Main risk
pull_request (default) No Yes Yes None — but secret-dependent steps must be skipped
pull_request_target, base code only Yes No Yes Low; easy to break later by adding a checkout
pull_request_target + checkout of PR head Yes Yes Yes Severe: full secret compromise ("pwn request")
workflow_run after the PR build Yes No (consumes artifacts) No, needs a manual commit status Artifact trust; path traversal when unzipping
Environment with required reviewers Yes, after approval Depends on trigger Yes Human approval fatigue
Rewrite the test to need no secret N/A Yes Yes Lower fidelity than the real service

The last row is the one most teams skip and shouldn't. A large share of secret-dependent CI steps are secret-dependent by accident: a test hitting a staging API that could hit a local stub, a registry login needed only for publishing, an integration suite that a service container would cover. Service containers and ephemeral local dependencies run fine in fork PRs because they need no credentials at all.

For the steps that genuinely cannot be faked, the honest default is: skip them on fork PRs, and make the skip visible.

jobs:
  test:
    runs-on: ubuntu-latest
    env:
      HAS_TOKEN: ${{ secrets.STAGING_API_TOKEN != '' }}
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run test:unit

      - name: Integration tests (requires credentials)
        if: env.HAS_TOKEN == 'true'
        run: npm run test:integration
        env:
          STAGING_API_TOKEN: ${{ secrets.STAGING_API_TOKEN }}

      - name: Explain skip
        if: env.HAS_TOKEN != 'true'
        run: echo "::notice::Integration tests skipped - fork PRs receive no secrets. A maintainer will run them."

Mapping the secret into a job-level env first is the part people get wrong; a bare secrets.* reference in a job-level if does not evaluate the way you expect, and the step-level check on env is the idiom that behaves consistently.

Takeaway: most "needs secrets" CI steps need a stub, not a credential — remove the dependency before you work around it.

How do you run the secret-dependent part safely?

The workflow_run pattern. Workflow A runs on pull_request, has no secrets, builds the PR and uploads what the second stage needs. Workflow B triggers on A's completion, runs in your repository's context with secrets, and touches only the artifact — never the PR's source or scripts.

# .github/workflows/pr-build.yml  (untrusted code, no secrets)
name: PR Build
on: pull_request
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run build
      - run: echo "${{ github.event.pull_request.number }}" > dist/pr-number.txt
      - uses: actions/upload-artifact@v4
        with:
          name: pr-dist
          path: dist/
# .github/workflows/pr-deploy-preview.yml  (trusted context, has secrets)
name: PR Preview
on:
  workflow_run:
    workflows: ["PR Build"]
    types: [completed]
permissions:
  contents: read
  statuses: write
jobs:
  preview:
    if: github.event.workflow_run.conclusion == 'success'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: pr-dist
          path: dist
          run-id: ${{ github.event.workflow_run.id }}
          github-token: ${{ secrets.GITHUB_TOKEN }}

      - name: Publish preview
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
        run: ./scripts/publish-preview.sh dist

      - name: Report status on the PR commit
        run: |
          gh api "repos/${{ github.repository }}/statuses/${{ github.event.workflow_run.head_sha }}" \
            -f state=success -f context=preview -f description="Preview published"
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Two details make or break this. First, workflow_run jobs do not show up as checks on the PR, which is why the last step posts a commit status explicitly — skip it and maintainers see no signal at all. Second, the deploy script must come from your checked-out repo or be inlined, never from the downloaded artifact; an artifact built by untrusted code is untrusted data. Treat it like a user upload: don't execute it, and be careful unzipping it into paths you then run.

If you need something simpler and are willing to put a human in the loop, pull_request_target combined with a deployment environment that has required reviewers gives you a gate: the job pauses until a maintainer approves, and only then does it see environment secrets. That is a reasonable choice for small repos with occasional outside contributions. If you want this without building it yourself, the managed preview-deployment products from hosting platforms like Vercel, Netlify, and Cloudflare already implement the fork case with their own credentials, which is exactly the problem you are otherwise solving by hand.

Takeaway: build untrusted code without secrets, then act on the artifact in a trusted workflow — never both in one job.

What about Dependabot PRs?

Dependabot-opened PRs behave like fork PRs for your regular repository secrets, which is why a working release pipeline suddenly fails there. Dependabot has its own separate secret store, and workflows triggered by Dependabot events read from that store rather than from Actions secrets. The practical consequence: a token Dependabot's workflows need must be added in the Dependabot secrets section, not only in the Actions one. Duplicating credentials across two stores is annoying, and it is a real maintenance cost of this design — rotating a token means rotating it twice.

Takeaway: if Dependabot PRs are the only red ones, you are looking at the wrong secret store.

FAQ

Why are GitHub secrets empty in pull requests from forks?
GitHub does not pass repository secrets to pull_request runs from forks, and limits GITHUB_TOKEN to read-only permissions, because the PR author controls the code being executed. There is no setting to change this; use a workflow_run or pull_request_target workflow that runs in your repository's context instead.

Is pull_request_target safe to use?
It is safe only if you never execute the pull request's code in it. The default checkout gives you the base branch, which is the safe behavior — the vulnerability appears when you add a checkout of github.event.pull_request.head.sha and then run build scripts, tests, or installs from it, because that combination hands your secrets to arbitrary code.

How do I show a workflow_run job's result on the pull request?
Post a commit status to github.event.workflow_run.head_sha with the Statuses API. workflow_run runs are not attached to the PR automatically, so without an explicit status or a PR comment the result is invisible to reviewers.

Bottom line

Start by auditing which secret-dependent steps actually need a credential; replace the ones that don't with service containers or stubs, and make the remaining skips loud with ::notice:: so contributors aren't confused by a half-green PR. When you truly need secrets against a contributor's changes, use the two-workflow workflow_run split and treat the artifact as untrusted data. Reach for pull_request_target only with the base checkout, or behind an environment with required reviewers, and audit any existing one today for a sneaky head-sha checkout. If preview deployments are the only reason you're building this, a hosting platform that already handles fork PRs is the cheaper answer.

Related reading

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.