For years, pull_request_target has been the canonical Pwn request vector: a workflow trigger that runs with repository secrets, yet can be triggered by anyone who opens a pull request from a fork. In 2026, GitHub responded with platform changes that make the default-case exploit significantly harder..
These changes represent real progress. Checkout protections, read-only cache tokens, and event policies close the paths that made exploitation trivial. But they don't eliminate the vulnerability class. They shift it. Attackers who previously exploited any pull_request_target workflow now need to find explicit opt-ins, outdated checkout versions, unprotected triggers, or script injection.
In Part 1 of our GitHub Actions research, we laid out the threat model and core defenses, and Part 2 covered AI-powered actions. This post updates that threat model for 2026: what GitHub's changes actually protect against, where the gaps remain, and how defenders need to adjust. The audit checklist at the end covers what to look for in your own workflows.
The 2026 Security Overhaul: Timeline of Changes
Recognizing the endemic risk posed by Pwn requests, GitHub spent 2026 systematically closing the gap through a phased rollout of platform-level protections and action updates.
| Date | Milestone & Action Taken |
|---|---|
| June 18, 2026 | Release of actions/checkout@v7.0.0, blocking fork code checkouts by default in untrusted triggers (pull_request_target, and workflow_run triggered by pull requests). |
| June 26, 2026 | GitHub introduces read-only cache tokens at the platform level for untrusted triggers, preventing cache poisoning attacks. |
| July 20, 2026 | Backported fork checkout protections across all active major versions of actions/checkout (v2.8.0, v3.7.0, v4.4.0, v5.1.0, and v6.1.0; v1 excluded). Workflows referencing mutable version tags (e.g., @v4) inherit these defenses automatically. |
| September 10, 2026 | Fine-grained cache controls via cache-mode reached General Availability (GA). |
| September 17, 2026 | Workflow Execution Protections reached GA, enabling fine-grained event policies and actor rules. |
| December 2, 2026 | [Scheduled] GitHub begins enforcing the default event policy that blocks pull_request_target in public repositories without an explicit event policy. The rule has been in evaluate mode since September 17. Private and internal repositories are not affected. |
Residual Risk: What Still Works
While this sequence of platform updates significantly strengthens default security configurations for GitHub Actions, they don't remove all risk.
The December 2 enforcement is a default, not a ban. Maintainers whose automation depends on pull_request_target (labelers, preview deploys, fork CI with secrets) will add an event policy that allows it. Those are exactly the workflows that handle untrusted input with elevated privileges. The default policy also doesn't apply to private and internal repositories or to workflow_run. The rest of this section covers how these surviving workflows can still be exploited.
SHA Pinning: The Security Paradox
One of the most disconcerting findings in our research is how standard supply chain hardening advice - specifically pinning actions to exact commit SHAs - can inadvertently bypass GitHub's platform defenses.
# "Security-hardened" workflow: SHA-pinned for supply chain safety
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0Because GitHub's backported protections rely on logic embedded within actions/checkout itself, pinning to SHA values prior to the July 20, 2026 backport release means the workflow runs legacy code without any safety checks. Our empirical testing across workflow triggers confirmed this discrepancy: referencing mutable tags like @v4 (resolving to v4.4.x) or pinned versions such as @v4.4.0 remain protected, whereas older versions like @v4.3.0 or SHA-pinned references to legacy releases (such as @08eba0b... resolving to v4.3.0) offer no protection.
To maintain protection, security teams using SHA pinning must ensure their refs are updated to match or exceed the minimum patched release versions: v2.8.0, v3.7.0, v4.4.0, v5.1.0, v6.1.0, or v7.0.0+.
Custom Checkout Scripts and Third-Party Actions
A second gap concerns workflows that bypass actions/checkout altogether. GitHub's protections are implemented within the action's code - any alternative method of fetching code operates unrestricted. Direct CLI invocations sidestep protections entirely:
- env:
GH_TOKEN: ${{ github.token }}
PR_REPO: ${{ github.event.pull_request.head.repo.full_name }}
PR_SHA: ${{ github.event.pull_request.head.sha }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
# Any one of these fetches fork code without actions/checkout's protections
# Bypass 1: Direct git clone
git clone "https://github.com/${PR_REPO}" pr-code && git -C pr-code checkout "${PR_SHA}"
# Bypass 2: gh pr checkout (runs in the base repo's workspace)
gh pr checkout "${PR_NUMBER}"
# Bypass 3: gh repo clone
gh repo clone "${PR_REPO}" fork-codeThird-party checkout actions implement their own git logic, so they don't inherit these protections. Any of them configured with a PR head ref will check out fork code unrestricted.
Unprotected Privileged Triggers
GitHub's checkout protections only cover pull_request_target and workflow_run:
Pwn requests triggered in other event types besides pull_request_target (such as issue_comment) will not be blocked by this change. Further hardening of additional events may be explored in future releases.
GitHub changelog
This creates an inconsistent security boundary: GitHub's cache controls explicitly treat issue_comment as a low-trust trigger, while checkout protections do not. Workflows using issue_comment for bot commands (/deploy, /test, /cherry-pick) remain fully exploitable with checkout v7 - no allow-unsafe-pr-checkout flag required:
on:
issue_comment:
types: [created]
jobs:
deploy:
if: github.event.issue.pull_request && contains(github.event.comment.body, '/deploy')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: refs/pull/${{ github.event.issue.number }}/merge # Checks out fork PR codeOther privileged triggers have the same gap. repository_dispatch also runs with secrets, and it's exploitable when a workflow uses attacker-influenced payload data to choose a checkout ref.
Until GitHub extends checkout protections, these workflows have to enforce their own authorization, and it needs to hold from the moment the event fires until the code runs:
- Verify who triggered it. For comment commands, check the commenter's actual repository permission (getCollaboratorPermissionLevel, write or above). Don't rely on author_association, which doesn't reflect current permissions. For repository_dispatch, limit who holds a token that can send the event, and don't treat the payload as proof of who sent it.
- Pin what was approved. refs/pull/N/merge is mutable, so an attacker can push a malicious commit after a maintainer comments /deploy. Check out the exact head SHA, and abort if the PR was updated after the comment was posted.
- Treat the event payload as untrusted input. Validate comment arguments and dispatch payload fields against a strict, anchored allowlist regex, and pass them to the shell through environment variables, never through ${{ }} interpolation.
Script Injection: Pwn Request Without Checkout
A common misconception is that pull_request_target vulnerabilities require checking out fork code. In reality, attackers can achieve code execution without any checkout step, through script injection.
When workflow authors interpolate event context directly into shell commands using ${{ }} expressions, GitHub substitutes the value into the script before the shell runs, so attacker-controlled data becomes part of the command itself:
on: pull_request_target
jobs:
greet:
runs-on: ubuntu-latest
steps:
- run: echo "Thanks for PR: ${{ github.event.pull_request.title }}"An attacker simply crafts a pull request with a malicious title:
"; touch /tmp/pwned; echo INJECTED #
The quote closes the string, the semicolon ends the echo, and everything after it runs as a new command with the job's secrets and token. None of GitHub's 2026 changes touch this: there is no checkout to block, and the vulnerability lies in how the workflow handles untrusted input.
The fix is to never interpolate untrusted input into a script. Pass it through an environment variable instead, so the shell treats it as data:
- env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: echo "Thanks for PR: $PR_TITLE"The double quotes around $PR_TITLE matter. Without them, the shell still word-splits and glob-expands the value.
Persistent Data Channels: Beyond Cache Tokens
GitHub's read-only cache tokens protect the Actions cache service, but workflows can still exchange data through channels that remain unaffected.
Artifact Poisoning
The upload-artifact and download-artifact actions operate independently of cache tokens. The classic attack chain uses an unprivileged pull_request workflow to upload attacker-controlled build outputs, then a privileged workflow_run to download and execute them:
# .github/workflows/build.yml - unprivileged, runs the fork's code
name: Build
on: pull_request
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: npm ci && npm run build # Attacker-controlled
- uses: actions/upload-artifact@v4
with:
name: build-output
path: ./dist/
# .github/workflows/deploy.yml - privileged, has secrets
name: Deploy Preview
on:
workflow_run:
workflows: ["Build"]
types: [completed]
jobs:
deploy:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: build-output
path: ./dist/
run-id: ${{ github.event.workflow_run.id }} # Fetches the fork run's artifact
github-token: ${{ secrets.GITHUB_TOKEN }}
- run: ./dist/deploy.sh # Executes attacker-controlled file
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} # Exposed to attacker codeThis is exactly the workflow_run scenario checkout v7 was built to block, and it doesn't help here. The privileged workflow never checks out fork code; it downloads it. Unlike cache entries, there's no platform equivalent that marks artifacts from untrusted runs as tainted, so any workflow that downloads and executes artifacts from a less-trusted run inherits the risk.
Self-Hosted Runner State
On non-ephemeral self-hosted runners, filesystem state persists between jobs. Local package manager caches (~/.npm, ~/.cache/pip), build directories, and installed tools live on the runner's disk, outside the Actions cache service, so read-only cache tokens don't cover them. Attacker code running under an untrusted trigger can write to these locations and poison the next trusted workflow scheduled on the same machine, including release and publish jobs. Use ephemeral runners, which are wiped after each job, for any workflow that untrusted events can trigger, and keep untrusted and trusted workflows in separate runner groups.
The Misconception That Won't Die
Many security teams believe they have another safeguard in place: the "Require approval for all external contributors" setting under Settings -> Actions -> General. If enabled, fork PRs need maintainer approval before workflows run. Problem solved, right?
Not quite. That setting only gates pull_request workflows, which run the fork's own workflow YAML. For pull_request_target, GitHub considers the workflow definition trusted (it comes from the base repository), so the approval setting doesn't apply and the run starts immediately.
The irony cuts deep: pull_request_target is the more dangerous trigger (secrets access, write tokens), yet it's the one that skips the approval requirement. If a pull_request_target workflow needs a human gate, put the privileged job behind an environment with required reviewers, or restrict who can trigger it with Workflow Execution Protections actor rules.
Explicit Opt-In: allow-unsafe-pr-checkout
Finally, developers who need to interact with fork code can explicitly disable checkout protections by enabling the allow-unsafe-pr-checkout flag:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.sha }}
allow-unsafe-pr-checkout: true # Re-enables untrusted fork checkoutsWhile the parameter name is deliberately alarming, determined development teams or third-party workflows may still employ it to maintain legacy automation, effectively reopening the exact same execution vector as before. Security teams should pay special attention to any use of this flag.
What Changed
To sum up, GitHub's 2026 security rollout deserves credit. Default event policies, checkout protections, and read-only cache tokens make the trivial Pwn request (fork, PR, profit) significantly harder. Starting December 2, pull_request_target will no longer run in public repositories unless an event policy explicitly allows it. Cache poisoning through the Actions cache is mitigated. These are real wins.
But the vulnerability class isn't eliminated. It shifted. What remains exploitable: explicit opt-ins via allow-unsafe-pr-checkout, SHA-pinned versions predating the July 2026 backport, custom checkout scripts that bypass action-level controls, script injection requiring no checkout at all, unprotected triggers like issue_comment and repository_dispatch, artifact poisoning chains from pull_request to workflow_run, persistent state on non-ephemeral self-hosted runners, and private or internal repositories, which the default policy doesn't cover. The attack surface shrinks, but what remains is harder to spot.
What To Do About It
Start by auditing what you already run, then get ahead of the December 2 enforcement.
Audit Your Workflows
Wiz Code automatically scans GitHub Actions workflows for Pwn requests, script injection, version gaps, and risky configurations to cover this residual attack surface. Alternatively, use open-source tools like zizmor or even these basic grep searches to start the audit:
# Flag risky triggers, unsafe checkouts, outdated checkout versions, artifact downloads,
# self-hosted runners and event data in expressions (env: usage is safe), then review each hit
grep -rnE 'pull_request_target|workflow_run|issue_comment|repository_dispatch|allow-unsafe-pr-checkout|self-hosted|download-artifact|gh (pr checkout|repo clone)|git (clone|fetch)|refs/pull|actions/checkout@v(1|2\.[0-7]|3\.[0-6]|4\.[0-3]|5\.0|6\.0)([^0-9]|$)|\$\{\{[[:space:]]*github\.(event\.|head_ref)' .github/workflows/In addition, run this script to resolve each SHA to its release tag, then compare against the patched versions:
tags=$(git ls-remote --tags https://github.com/actions/checkout)
grep -rhoE "actions/checkout@[0-9a-f]{40}" .github/workflows/ | cut -d@ -f2 | sort -u |
while read -r sha; do
tag=$(echo "$tags" | grep "^$sha" | sed 's#.*refs/tags/##; s#\^{}$##' | sort -V | tail -1)
echo "$sha ${tag:-UNKNOWN - not a release tag, check manually}"
donePreparing for the December 2 pull_request_target Enforcement
Starting December 2, the default event policy will block pull_request_target in public repositories that have no explicit event policy. workflow_run is not affected, and neither are private or internal repositories. Before that date:
Check policy insights to see which pull_request_target runs would be blocked.
Decide per workflow whether it actually needs pull_request_target. If not, move it to pull_request. If a privileged follow-up step is still needed, a workflow_run workflow can handle it, but it must treat the PR run's artifacts as untrusted data, never execute them.
For workflows that legitimately need fork access, create an explicit event policy and use workflow file targeting to allow pull_request_target only for those files. Treat every file on that allowlist as high-risk: run it through the audit above before adding it, and re-audit whenever it changes.