</>CodeWithKarani

The GitHub Actions Pwn Request: How Fork PRs Steal Your Secrets

Karani GeoffreyKarani Geoffrey8 min read

There is a workflow pattern that looks completely reasonable and hands your repository secrets to anyone on the internet who opens a pull request. It is not exotic. It is the natural thing you write when you want CI to build and test contributions from forks, and it has been the root cause of real supply-chain compromises, including the tj-actions/changed-files incident in March 2025 that leaked secrets from thousands of repositories into publicly readable logs.

The pattern is pull_request_target plus a checkout of the fork's code. Each half is fine on its own. Together they are a remote code execution hole with your GITHUB_TOKEN and every secret in the repo. As of mid-2026, GitHub has finally made the newest actions/checkout refuse this combination by default, which means a lot of pipelines that "worked" for years are now failing on purpose. This article explains why that failure is correct, and how to build the thing you actually wanted without reopening the hole.

Never check out and run a fork's PR code in a workflow triggered by pull_request_target. That trigger runs with your secrets and a write-scoped token, so running attacker code under it is a "pwn request."

  • For build-and-test of untrusted PRs, use the plain pull_request trigger. It has no secrets and a read-only token by default. Attacker code runs, but with nothing worth stealing.
  • If you need to post labels or comments on fork PRs, split it: an untrusted pull_request job that only uploads an artifact, and a separate workflow_run job that consumes the artifact with privileges and never checks out fork code.
  • Pin third-party actions to a full commit SHA, not a tag. Tags are mutable and are exactly how the tj-actions compromise spread.

Why pull_request_target exists, and why that makes it dangerous

When a pull request comes from a fork, the ordinary pull_request trigger deliberately runs with almost no power: the GITHUB_TOKEN is read-only and repository secrets are not exposed. That is by design, because the code being tested is written by a stranger. The problem is that maintainers legitimately need some privileged automation on fork PRs: apply a label, post a coverage comment, assign a reviewer. Those need write access, which the untrusted trigger will not give you.

pull_request_target is GitHub's answer. It runs the workflow in the context of the base repository, not the fork. It gets a read-write GITHUB_TOKEN and access to secrets, and, crucially, by default it checks out the base branch's workflow and code, not the fork's. Used as intended, that is safe: your trusted code runs with privileges and looks at metadata about the PR.

The vulnerability appears the moment you add an explicit checkout of the pull request head so you can build it:

# DANGEROUS: this is the pwn request. Do not ship this.
on:
  pull_request_target

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}   # the fork's code
      - run: npm ci && npm test                             # runs the fork's code
        env:
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}               # ...with your secrets

Now think like the attacker who opens a PR against that repo. They do not need to touch your workflow file, which is read from the base branch and stays trusted. They only need to change something the build executes: a package.json postinstall script, a test file, a Makefile target, a conftest.py. That code runs on your runner, in the privileged context, with secrets.NPM_TOKEN and a write-scoped GITHUB_TOKEN sitting in the environment. Exfiltrating them is a one-liner that prints them to the log or curls them to a server the attacker controls.

Attacker opens PR from a fork pull_request_target runs with secrets + write token checkout: pull_request.head npm ci runs attacker code Secrets exfiltrated printed to log or curled out The fix: keep the untrusted checkout and the privileges in different workflows. A pull_request job builds with no secrets. A workflow_run job holds the privileges and never touches fork code.
The two ingredients are privilege and untrusted code in the same job. Remove either one and the attack disappears.

The tj-actions/changed-files lesson

In March 2025, the widely used tj-actions/changed-files action was compromised. The attacker altered the code that many v-tagged releases pointed at, so that any workflow using it dumped the runner's secrets into the build log. Because those logs are public on public repositories, the secrets were readable by anyone. It spread across a very large number of repositories precisely because everyone pinned to a mutable tag like @v35 rather than to an immutable commit SHA, so re-pointing the tag silently updated every consumer at once.

The two takeaways are independent and you need both. First, do not run untrusted code in a privileged context (the pwn request). Second, do not trust a third-party action's tag to keep meaning what it meant yesterday (the supply-chain pin). A repo can be careful about one and get burned by the other.

Why the "just switch to pull_request" advice stalls halfway

The common remediation is "use pull_request instead of pull_request_target." That is correct for the build-and-test part, and if that is all your workflow does, it is the whole fix: attacker code still runs, but the token is read-only and there are no secrets to steal.

Where people get stuck is that the same workflow also posted a comment, or added a label, or updated a status. Under plain pull_request from a fork, those calls now fail with a permissions error, because the token is read-only. So the tempting next move is to add permissions back, or to sneak pull_request_target back in "just for the labelling step," and the hole quietly reopens. The mistake is trying to do both jobs, running untrusted code and exercising write privileges, in one workflow. They must be separated.

The fix: two workflows that never share a context

Step 1: build and test untrusted code with no privileges

This workflow uses pull_request, so it has a read-only token and no secrets. It runs the fork's code, produces whatever result you need (test output, a coverage file, the PR number), and uploads it as an artifact. It never touches your secrets, so it does not matter that attacker code runs here.

name: pr-build
on:
  pull_request:

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4          # fork code, but no secrets in scope
      - run: npm ci && npm test
      - run: echo "${{ github.event.number }}" > pr-number.txt
      - uses: actions/upload-artifact@v4
        with:
          name: pr-result
          path: pr-number.txt

Step 2: act on the result with privileges, without the fork's code

This second workflow is triggered by workflow_run, which fires after the first one completes and runs in the context of the base repository. It has your secrets and a write token. Critically, it does not check out the fork's code. It only downloads the artifact the first workflow produced and acts on it.

name: pr-comment
on:
  workflow_run:
    workflows: ["pr-build"]
    types: [completed]

permissions:
  pull-requests: write

jobs:
  comment:
    runs-on: ubuntu-latest
    if: ${{ github.event.workflow_run.event == 'pull_request' }}
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: pr-result
          run-id: ${{ github.event.workflow_run.id }}
          github-token: ${{ secrets.GITHUB_TOKEN }}
      # act on pr-number.txt with the privileged token here.
      # Treat the artifact contents as untrusted input: never eval it,
      # never pass it unescaped into a shell.

The privileged job only ever sees a data file. It never executes anything the attacker wrote. That is the entire security boundary, and it is why this split is the recommended pattern rather than a workaround.

Step 3: pin every third-party action to a commit SHA

Replace mutable tags with the full 40-character commit SHA, and leave the human-readable version in a comment:

      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2

Now a compromised upstream re-pointing its tag cannot reach you, because you are pinned to an exact commit. Enable Dependabot for actions so you still get updates, but as reviewable pull requests rather than silent tag moves.

What GitHub changed in 2026, and why your pipeline may start failing

In June 2026 GitHub shipped safer defaults in the newest actions/checkout, and the backports to older supported major versions were enforced from around 20 July 2026. The new behaviour: actions/checkout refuses to fetch a fork pull request's code when it is running under a pull_request_target event (and under workflow_run when the underlying event was a pull request), if the requested ref resolves to that fork PR's head or merge commit. In other words, the exact pwn-request checkout now errors out instead of quietly running.

If a pipeline of yours suddenly fails at the checkout step after that change, do not reach for a flag to force it. Treat the failure as the system correctly telling you that workflow was exploitable, and move it to the two-workflow pattern above. Workflows pinned to a floating major tag such as @v4 receive the protection automatically; only the very old v1 is unaffected.

Verification: prove the privileged path cannot see fork code

Confirm the split is real with three checks. First, in the build workflow, verify there are no secrets in scope: search the workflow for any secrets. reference and remove it. Second, in the privileged workflow, confirm there is no actions/checkout of a PR ref anywhere. Third, open a test pull request from a throwaway fork that adds a harmless postinstall script echoing a marker string, and confirm the marker appears only in the unprivileged pr-build log and never in the pr-comment job. If your organisation uses branch protection and required reviews, also gate secret-bearing environments behind required reviewers so even a privileged workflow needs human approval before it runs.

For the wider habit of keeping credentials out of CI in the first place, the piece on a secrets workflow for small teams using SOPS and age pairs well with this, and if you deploy from Actions to your own box, shipping to a VPS with GitHub Actions shows where those deploy secrets should and should not live.

When it is still broken

If the privileged workflow cannot post its comment, check that its permissions block actually grants what the API call needs (pull-requests: write for comments and labels, statuses: write for commit statuses) and that organisation or repository settings have not capped the GITHUB_TOKEN to read-only. If download-artifact comes back empty, the artifact name or run-id is wrong, or the build job failed before uploading. If you genuinely cannot avoid running a fork's build with a secret (for example an integration test that needs a real credential), do not solve it with pull_request_target; require a maintainer to apply a label that gates the privileged run, and scope the secret to a dedicated, low-value account.

Frequently asked questions

What is a pwn request in GitHub Actions?
A pwn request is when a workflow triggered by pull_request_target checks out and runs a fork pull request's code. pull_request_target runs with the base repository's secrets and a write-scoped GITHUB_TOKEN, so executing attacker-controlled code under it lets that attacker read your secrets and use your token. It has caused real supply-chain incidents.
How do I test and comment on a fork PR without exposing secrets?
Split the work into two workflows. An untrusted pull_request workflow with no secrets and a read-only token builds and tests the fork code, then uploads a result artifact. A separate workflow_run workflow, which never checks out fork code, downloads that artifact and posts comments or labels using its privileges. The privileged job only ever sees data, never executes the attacker's code.
Why did my GitHub Actions checkout step suddenly start failing in 2026?
In June 2026 GitHub made actions/checkout refuse to fetch a fork pull request's code when running under pull_request_target, with backports enforced from around 20 July 2026. If your checkout step now errors, it is because that workflow was a pwn request. Do not force it; move the untrusted build into a separate pull_request workflow instead.
Should I pin GitHub Actions to a tag or a commit SHA?
Pin to the full 40-character commit SHA, not a tag. Tags like @v4 are mutable, and the tj-actions/changed-files compromise in March 2025 spread precisely because attackers re-pointed a tag that thousands of repos trusted. Pin to a SHA and use Dependabot so updates arrive as reviewable pull requests.
#GitHub Actions#CI/CD#Supply Chain#Secrets#Security
Keep reading

Related articles