Self-hosted runners on a public repository are dangerous because any GitHub user can open a pull request whose workflow runs on your machine, and a runner that survives the job keeps whatever that code left for the next, privileged job. GitHub says they "should almost never be used for public repositories"; if you must, run one job per freshly built machine, require approval for all external contributors, and keep public repositories out of your runner groups.

The path from the runner-images and PyTorch write-ups. Approval breaks step 2, runner groups step 3, one job per machine steps 4 and 5.
A GitHub-hosted runner is a fresh virtual machine thrown away after the job. A self-hosted runner is whatever you registered: by default a long-lived machine that picks up job after job, keeps its work directory, and sits inside your network. On a private repository the people who can trigger it are people you gave access. On a public one it is anyone with an account.
That one exposure is the subject here, set apart from the rest of GitHub Actions security: GitHub's warning, the attack path two researchers used against GitHub's own runner-images repository and against PyTorch, and the three settings that break it.
What GitHub says
GitHub's secure use reference warns that self-hosted runners "can be persistently compromised by untrusted code in a workflow", and they "should almost never be used for public repositories on GitHub, because any user can open pull requests against the repository and compromise the environment." The runner groups documentation repeats it as a warning: forks of a public repository can run dangerous code on your runner by opening a pull request.
The same reference is skeptical of the obvious workaround. Destroying the runner after each job with your own automation "might not be as effective as intended, as there is no way to guarantee that a self-hosted runner only runs one job", and secrets passed as command-line arguments can be read by another job on the same runner with ps x -w. What it points to instead is registration that GitHub itself limits to one job, covered under the first fix below.
Why a persistent runner is the problem
A fork pull request gets no secrets and a read-only token, so the first job is not the prize. The prize is the job after it. When the machine survives, so does whatever the fork job installed: a background process, a modified toolchain, or a second runner agent. The next job to land there might be a push to main or a release, with the repository's secrets and a write-capable GITHUB_TOKEN.
Both public write-ups stayed the same way, a technique they call runner on runner: install another copy of the official runner agent, registered to a repository the attacker controls, and use that repository's workflows as a remote shell. It needs no malware beyond GitHub's own binary, and it outlives the job that installed it. The workflow that lets it in is three lines long:
on: pull_request # any fork can open one jobs: test: runs-on: [self-hosted, linux] # so fork code runs on your machine steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - run: make test # and the contributor wrote the Makefile runner-images and PyTorch
In both, the approval setting was GitHub's default, which only holds back first-time contributors, and the runner was persistent. A merged typo fix was enough to stop being first-time.
| Target | Write-up | How they got in | What was reachable |
|---|---|---|---|
actions/runner-images, which builds GitHub-hosted runner images | Adnan Khan, December 20, 2023 | A one-character typo fix merged on July 20, 2023 made the account a contributor; its next pull request ran on the self-hosted runners without approval | A write-capable GITHUB_TOKEN from .git/config, secrets left in the runner's _temp directory, internal vCenter access; paid a $20,000 bounty |
| PyTorch | John Stawinski with Adnan Khan, January 11, 2024 | A typo fix in a markdown file, then a draft pull request whose workflow ran on PyTorch's self-hosted runners | GITHUB_TOKEN values with write access, two personal access tokens, and AWS keys able to upload PyTorch releases |
Both write-ups read 2026-11-12. Khan's post records initial mitigations by GitHub on July 25, 2023 and the report resolved on November 14, 2023.
Fix 1: one job per machine, then destroy it
Register runners so that GitHub assigns them one job. With --ephemeral on config.sh, the service de-registers the runner after it has processed one job. A just-in-time runner is created through the REST API instead of a registration token, and performs at most one job before GitHub removes it from the repository, organization or enterprise. GitHub's own guidance calls ephemeral runners the way to limit exposure from previous jobs and to stop a compromised runner receiving new ones.
De-registration is not destruction. Your automation still has to throw the machine away and build a clean one, and GitHub warns that re-using hardware for JIT runners "can risk exposing information from the environment." If you would rather not operate that loop, migrating off self-hosted runners covers the move, and ephemeral runners explained covers what changes for caches and Docker layers when every machine starts empty.
# Ephemeral: GitHub de-registers the runner after one job ./config.sh --url https://github.com/octo-org --token "$REG_TOKEN" --ephemeral # Just-in-time: one runner, one job, configuration issued by the API gh api -X POST repos/octo-org/octo-repo/actions/runners/generate-jitconfig \ -f name=jit-01 -F runner_group_id=1 -f 'labels[]=self-hosted' \ --jq .encoded_jit_config > jit.b64 ./run.sh --jitconfig "$(cat jit.b64)" Flags and endpoint from docs.github.com (self-hosted runners reference, REST API endpoints for self-hosted runners), read 2026-11-12. The JIT endpoint needs admin access to the repository, or Administration write on a fine-grained token.
Fix 2: require approval for every external contributor
The setting is under Settings, Actions, General: Approval for running fork pull request workflows from contributors. Its default holds back first-time contributors only, and GitHub's documentation now warns about the gap both write-ups used: an account that has had any commit or pull request merged no longer needs approval, and a typo fix is enough.
Choose Require approval for all external contributors. Every pull request from someone who is not a member of the repository or organization then waits for a maintainer with write access to approve the run. Two limits remain. Approval is a review step, so an approved run of malicious code still runs on your machine, and pull_request_target workflows run in the base branch context and always run, regardless of approval settings.
| Setting | Who waits for approval |
|---|---|
| Require approval for first-time contributors who are new to GitHub | Accounts new to GitHub that have never had a commit or pull request merged here |
| Require approval for first-time contributors (the default) | Anyone who has never had a commit or pull request merged here |
| Require approval for all external contributors | Everyone who is not a member or owner of the repository or organization |
Option names and the warning from docs.github.com, managing GitHub Actions settings for a repository, read 2026-11-12. Waiting for approval to run workflows covers the approval screen itself.
Fix 3: keep public repositories out of the runner group
Organization runners live in runner groups, and a group's access policy decides which repositories can send it jobs. By default only private repositories can use a runner group; allowing public ones is an override, and the first thing to check is whether someone set it. Narrow each group to selected repositories, and where you can to selected workflows, so one compromised runner does not see every repository's jobs.
Two settings close side doors. Organization owners can choose which repositories may create repository-level runners, which stops a repository admin attaching a machine outside your groups. And because GitHub can schedule jobs from many repositories onto one organization-level runner, splitting runners into groups by trust level is what keeps a compromise of one from becoming a compromise of all.
# Which runner groups exist, and does any of them accept public repositories? gh api orgs/octo-org/actions/runner-groups \ --jq '.runner_groups[] | {name, visibility, allows_public_repositories, restricted_to_workflows}' FAQ
Is it safe to use self-hosted runners on a public repository?
GitHub's answer is that they should almost never be used there, because any user can open a pull request that runs code on the machine. If you must, run each job on a machine built fresh for it and destroyed after, and require approval for all external contributors.
What is the difference between an ephemeral runner and a just-in-time runner?
Both take one job. An ephemeral runner is registered with config.sh --ephemeral and GitHub de-registers it after that job. A just-in-time runner is created from a configuration the REST API issues for that one runner, and is removed after its job. Neither wipes the machine for you.
Do fork pull requests get secrets on self-hosted runners?
No. Apart from GITHUB_TOKEN, secrets are not passed to workflows triggered from forks, on any runner. The risk on a persistent runner is the next job: fork code that stays on the machine can read the secrets of the trusted job that lands there after it.
Does requiring approval for fork pull requests make self-hosted runners safe?
It narrows who can run code without review, but an approved pull request still runs on your machine, and pull_request_target workflows run regardless of approval settings. Approval is the second line; one job per destroyed machine is the first.
References
- Secure use reference: hardening for self-hosted runners, JIT runners (read 2026-11-12)
- Managing GitHub Actions settings for a repository: fork pull request approval (read 2026-11-12)
- Managing access to self-hosted runners using groups (read 2026-11-12)
- Adnan Khan: One Supply Chain Attack to Rule Them All, poisoning GitHub's runner images (2023-12-20)
- John Stawinski: Playing with Fire, a critical supply chain attack on PyTorch (2024-01-11)