From December 2, 2026, pull_request_target is blocked, at least in public repositories where nobody has set up a matching rule. GitHub announced the change on November 2 and pushed the date back by one month at the same time. That sounds like plenty of time. However, two trial runs in which the block is briefly enforced already take place on November 9 and 16.
I care about this because pull_request_target is one of those triggers almost everyone has copied from some example at some point without really knowing what it does. That is exactly why I think the block is the right call. In this article I show you how to find affected workflows, how to decide for each one, and what the safe alternatives look like in practice, including a small scanner written in TypeScript.
In short
In public repositories,
pull_request_targetis blocked by default from December 2, 2026, because the trigger can bring fork code and secrets together. Most workflows can move topull_requestor be split withworkflow_run, and the rest gets an explicit allowance through an Actions policy. The biggest pitfall: the trial blocks on November 9 and 16, when unchecked workflows already fail.
What you will learn in this article
- What changes on December 2 and which repositories are affected
- Why
pull_request_targetcan lead to “pwn requests” - How to find affected workflows with policy insights, gh and a scanner
- Moving workflows to
pull_requestor splitting them withworkflow_run - Allowing individual workflows through an Actions policy
pull_request_target blocked from December 2: what changes
First, the term: a trigger defines which event starts a GitHub Actions workflow. Both pull_request and pull_request_target react to pull requests. However, they differ in context. With pull_request_target, the workflow runs as if it came from the target repository itself, with its secrets and a GITHUB_TOKEN that may write. For a pull request from a fork, on the other hand, pull_request only gets a read-only token and no secrets.
To get pull_request_target blocked by default, GitHub introduces a default rule. It belongs to workflow execution protections, a kind of allowlist for who may start workflows and on which events. Here are the dates:
| Date | What happens |
|---|---|
| until December 2 | default rule runs in “evaluate” mode: nothing is blocked, but runs are recorded |
| November 9, 2026, 16:00–18:00 UTC | first trial block: affected runs fail with an error annotation |
| November 16, 2026, 16:00–18:00 UTC | second trial block, same behavior |
| December 2, 2026 | permanent block for pull_request_target without a matching policy |
GitHub gives the windows in UTC. In Central Europe that is 17:00 to 19:00 in November.
Who is affected
Affected are public repositories on GitHub Free, Team and Enterprise Cloud. Private and internal repositories are out of scope, and so is GitHub Enterprise Server. In addition, the default rule does not apply if you have already created your own event policy for the repository. It does not replace that policy.
Why GitHub has pull_request_target blocked
On its own, the trigger is not evil. As long as the workflow only runs code from your own repository, nothing bad happens. Under pull_request_target, actions/checkout without extra inputs also fetches only the state of the default branch. Things get dangerous when someone checks out the code from the pull request and then does something with it: npm install, make test, a build script. As a result, foreign code runs with your secrets.
What a pwn request is
This pattern has its own name: pwn request. Anyone who creates a fork can, for example, add a postinstall script to package.json, change a Makefile or tamper with a test file. If they then open a pull request and your workflow builds the code, deploy keys, npm tokens or cloud credentials end up with the attacker. In fact, several well-known attacks on open source supply chains worked exactly like this.
🎬 Pwn request and safe rework compared – this animated diagram only runs on my site: watch it on sascha-ms.de
What makes it tricky is that the code does not have to come through actions/checkout. A gh pr checkout in a run step, a git fetch of the PR ref or a downloaded artifact from a fork have the same effect. By the way, since version 7, actions/checkout refuses on its own to check out fork code under pull_request_target or workflow_run. If you want it anyway, you have to set allow-unsafe-pr-checkout: true. That name is deliberately uncomfortable.
Step 1: Find affected workflows
Before pull_request_target is blocked and runs start failing, you need a list. In general, there are three ways to build it, and they complement each other well. Prerequisite: admin rights in the repository or owner rights in the organization for the policy pages, otherwise just read access and the GitHub CLI for searching.
Policy insights in the settings
- Open Settings in the repository or organization.
- Open the Actions section in the sidebar and choose Policies.
- Right below it you will find Policy insights. It lists the runs a rule in evaluate mode would have blocked.
On the upside, you see real runs instead of guesses. However, a workflow that has not run for weeks does not show up there. That is why a look at the code is worth it, too.
Search all repositories of an organization
With the GitHub CLI you can search all repositories of an organization for the trigger. In this example the fictional organization is called velo-verleih, a bike rental with a public app:
gh search code "pull_request_target path:.github/workflows" \
--owner velo-verleih --limit 100 \
--json repository,path \
--jq '.[] | "\(.repository.nameWithOwner) \(.path)"'
As a result, you get one line per match, with repository and file path. Code search only covers the default branch and does not return everything when there are many hits. As a first overview it is good enough, though.
Check workflows locally with a small scanner
On its own, however, a match does not tell you whether a workflow is dangerous. A labeler that only reads metadata is harmless. A preview build that checks out the PR code is not. That is why I wrote a small scanner for this distinction. First, it reads all YAML files in a folder; then it looks for the trigger and checks typical pwn request patterns.
// src/scan-workflows.ts
import { parse } from "yaml";
export type Severity = "ok" | "review" | "pwn-risk";
export interface Finding {
file: string;
severity: Severity;
reasons: string[];
}
type Step = { uses?: string; run?: string; with?: Record<string, unknown> };
type Job = { steps?: Step[]; permissions?: unknown };
type Workflow = { on?: unknown; permissions?: unknown; jobs?: Record<string, Job> };
// Expressions that point to code from the pull request (possibly a fork)
const PR_HEAD_REF =
/github\.event\.pull_request\.head\.(sha|ref|repo)|github\.head_ref|refs\/pull\//;
export function usesPullRequestTarget(on: unknown): boolean {
if (typeof on === "string") return on === "pull_request_target";
if (Array.isArray(on)) return on.includes("pull_request_target");
if (on && typeof on === "object") return "pull_request_target" in on;
return false;
}
export function scanWorkflow(file: string, source: string): Finding {
const wf = (parse(source) ?? {}) as Workflow;
if (!usesPullRequestTarget(wf.on)) {
return { file, severity: "ok", reasons: [] };
}
const reasons: string[] = ["uses pull_request_target"];
let pwnRisk = false;
if (wf.permissions === undefined) {
reasons.push("no top-level permissions block (token may have broad rights)");
}
for (const [jobName, job] of Object.entries(wf.jobs ?? {})) {
for (const step of job.steps ?? []) {
const uses = step.uses ?? "";
const withArgs = step.with ?? {};
if (uses.startsWith("actions/checkout")) {
const ref = String(withArgs.ref ?? "");
const repo = String(withArgs.repository ?? "");
if (PR_HEAD_REF.test(ref) || PR_HEAD_REF.test(repo)) {
pwnRisk = true;
reasons.push(`job "${jobName}": checkout of pull request code`);
}
if (withArgs["allow-unsafe-pr-checkout"] === true) {
pwnRisk = true;
reasons.push(`job "${jobName}": allow-unsafe-pr-checkout is enabled`);
}
}
const run = step.run ?? "";
if (/gh pr checkout|git fetch[^\n]*(pull\/|head)/.test(run)) {
pwnRisk = true;
reasons.push(`job "${jobName}": fetches pull request code in a run step`);
}
}
}
return { file, severity: pwnRisk ? "pwn-risk" : "review", reasons };
}
In addition, the command line script for it is short. It also sets an exit code, so you can use the scanner in a pipeline later:
// src/cli.ts
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { scanWorkflow } from "./scan-workflows.ts";
const dir = process.argv[2] ?? ".github/workflows";
const files = (await readdir(dir)).filter((f) => /\.ya?ml$/.test(f));
let worst = 0;
for (const name of files) {
const finding = scanWorkflow(name, await readFile(join(dir, name), "utf8"));
if (finding.severity === "ok") continue;
worst = Math.max(worst, finding.severity === "pwn-risk" ? 2 : 1);
console.log(`${finding.severity.toUpperCase()} ${name}`);
for (const reason of finding.reasons) console.log(` - ${reason}`);
}
if (worst === 0) console.log(`No pull_request_target workflows found in ${dir}.`);
// Exit code 2 = pwn risk, 1 = review needed, 0 = nothing found
process.exitCode = worst;
You need Node.js 22.18 or later, because the project runs TypeScript files directly. This is how you start it:
mkdir pull-request-target-check && cd pull-request-target-check
npm init -y && npm pkg set type=module
npm install yaml@2.9.1
mkdir src # put scan-workflows.ts and cli.ts from this article here
node src/cli.ts ~/code/fahrradverleih-app/.github/workflows
For a repository with a risky preview workflow and a harmless labeler, the output looks like this:
PWN-RISK preview.yml
- uses pull_request_target
- no top-level permissions block (token may have broad rights)
- job "preview": checkout of pull request code
REVIEW labeler.yml
- uses pull_request_target
To be fair, the scanner is a heuristic. It does not catch shell tricks across several lines or composite actions that check something out internally. For a quick assessment of a few dozen workflows it still does the job. If you want more, also enable CodeQL for Actions, which GitHub recommends for such checks.
Step 2: Decide for each workflow
Next, go through every workflow on the list one by one. The question is always the same: does this workflow really need secrets or write access while it reacts to a pull request? Most of the time the answer is no.
| What the workflow does | My recommendation |
|---|---|
| Tests, linting, build without secrets | move to pull_request
|
| Build followed by a comment, status or upload with secrets | split: pull_request plus workflow_run
|
| Set labels, post a welcome message, check metadata | keep pull_request_target and allow it explicitly |
| Preview deployment with cloud access for fork PRs | rethink it: only after manual approval, or not for forks at all |
Step 3: Move to pull_request
First of all, the simplest case is also the most common one. Many workflows only use pull_request_target because at some point someone wanted them to run for forks as well. However, pull_request does that too, just without secrets. For tests and linting that is exactly right:
name: lint
on:
- pull_request_target:
+ pull_request:
branches: [main]
permissions:
contents: read
Afterwards, check whether the workflow reads a secret anywhere. If it does, that step will fail for fork PRs from now on. Then it belongs in the privileged part from step 4.
Step 4: Split workflows with workflow_run
It gets trickier when a workflow needs both: build foreign code and then do something with privileges, such as posting a comment with the test coverage. My solution: separate these two jobs into two workflows. First, one workflow runs on pull_request, builds and tests without secrets and only stores data as an artifact. After that, a second one starts via workflow_run once the first has finished, and it has the required permissions. It reads the data but never executes code from it.
The build runs without secrets, the comment workflow only reads data.
For example, here is the unprivileged part for the bike rental:
# .github/workflows/pr-build.yml
# Runs untrusted pull request code: read-only token, no secrets
name: pr-build
on:
pull_request:
branches: [main]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
- run: npm ci
- run: npm test -- --coverage > coverage.txt
- name: Save results as data
run: |
mkdir -p result
echo "${{ github.event.pull_request.number }}" > result/pr-number
tail -n 5 coverage.txt > result/summary.txt
- uses: actions/upload-artifact@v7
with:
name: pr-result
path: result/
retention-days: 3
The privileged part
And here is the privileged part. Watch for three details: the artifact goes to runner.temp instead of the workspace, the PR number is validated as an integer, and the comment text is only read and truncated, never executed.
# .github/workflows/pr-comment.yml
# Privileged part: may write comments, but never executes PR code
name: pr-comment
on:
workflow_run:
workflows: [pr-build]
types: [completed]
permissions:
contents: read
pull-requests: write
jobs:
comment:
if: >
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v8
with:
name: pr-result
path: ${{ runner.temp }}/pr-result
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
- name: Validate and comment
env:
GH_TOKEN: ${{ github.token }}
RESULT_DIR: ${{ runner.temp }}/pr-result
run: |
PR=$(cat "$RESULT_DIR/pr-number")
if ! [[ "$PR" =~ ^[0-9]+$ ]]; then
echo "Invalid PR number" >&2
exit 1
fi
{
echo "### Test coverage"
echo '```
{% endraw %}
'
head -c 2000 "$RESULT_DIR/summary.txt"
echo '
{% raw %}
```'
} > "$RUNNER_TEMP/comment.md"
gh pr comment "$PR" --repo "$GITHUB_REPOSITORY" --body-file "$RUNNER_TEMP/comment.md"
One important point: everything in the artifact comes from the attacker. The PR number could be forged, and the text can contain anything. Still, for a comment that is acceptable. Running a binary from the artifact, on the other hand, would be the same mistake as before, just one detour later. If you want to be completely safe, look up the PR number through the API based on the head commit instead of reading it from the artifact.
Step 5: Allow pull_request_target explicitly
Nevertheless, some workflows really need the trigger. The classic case is a labeler that tags pull requests based on the changed paths. It has to write to fork PRs, but it only reads metadata:
# .github/workflows/labeler.yml
# Legitimate use: only reads metadata, never checks out PR code
name: labeler
on:
pull_request_target:
types: [opened, synchronize]
permissions:
contents: read
pull-requests: write
jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v7
Create the event policy
For workflows like this, create your own event policy and limit it to exactly this file. In a repository it works like this:
- Open Settings, choose Actions and then Policies in the sidebar, and create a new policy.
- Give it a descriptive name, for example “labeler may use pull_request_target”.
- As the target, enter only the workflow path
.github/workflows/labeler.yml, not all workflows. - Under Restrict events, allow every event this workflow reacts to, here
pull_request_target. - Set enforcement to Active and save. On Enterprise Cloud you can test with Evaluate first.
In particular, step four is easy to underestimate. An event policy is an allowlist: anything not listed is blocked for the targeted workflows. If your workflow also reacts to workflow_dispatch, add it to the list. You can also see existing policies in the repository through the API:
gh api repos/velo-verleih/fahrradverleih-app/actions/policies \
--jq '.policies[] | "\(.name) \(.enforcement)"'
For this you need a token with administration rights for the repository. In other words, better make the policy too narrow than too broad. A rule that allows pull_request_target for all workflows makes the new block pointless.
Step 6: Use the trial block on November 9
You can put the two trial blocks to good use. On November 9 between 16:00 and 18:00 UTC, open a pull request from a fork or a test account and watch what happens. When pull_request_target is blocked, the runs show up as failed with an error annotation, and the workflow itself does not start. After the window, workflows run again, just with warnings.
It also helps to give a short heads-up to everyone reviewing pull requests during those two hours. Otherwise someone spends an hour hunting a bug that is not one. On November 16 you repeat the test with the reworked workflows.
Common mistakes during the rework
Above all, the most common mistake is a policy that allows too much. If you enable pull_request_target for all workflows under time pressure, you will have peace on December 2 and the same hole as before. Better a workflow that does not run for a week than a deploy key that ends up with strangers.
Second, people tend to assume workflow_run is safe by design. It is not, because secrets are available there too, and if you unpack and run the artifact from the first workflow, you simply rebuild the pwn request. Third, old pull requests stay open that still contain the vulnerable workflow version. Close those PRs or ask for them to be rebased onto the current state.
A final point concerns permissions. Set a permissions block in every workflow and grant only what is really needed. Together with pinned actions from my article on the GitHub Actions lockfile, that is a solid foundation. The policy page can also enforce lockfiles, so the two topics belong closely together.
My take
Having pull_request_target blocked by default is overdue in my view. pull_request_target has been a known trap for years, and documentation alone was clearly not enough. Secure by default, with exceptions only on purpose: to me that is the right direction, whether for cloud permissions or CI pipelines.
On the other hand, I see the short lead time for small open source projects critically. If you maintain a project in your spare time, you do not read every changelog entry. For teams in companies it is the opposite: there is no excuse. Take an hour, go through the list, and you have made your pipeline safer along the way. If your team is thinking about AI agents in GitHub Actions anyway, now is a good moment to clean up the permissions of all workflows once.
FAQ
Are private repositories affected by the block?
No. The default rule only applies to public repositories on GitHub Free, Team and Enterprise Cloud. Private and internal repositories as well as GitHub Enterprise Server are not affected.
What is the difference between pull_request and pull_request_target?
For fork PRs, pull_request runs with a read-only token and without secrets. pull_request_target executes as if it belonged to the target repository, with secrets and write access, and uses the workflow file from the default branch.
What happens during the trial blocks in November?
On November 9 and 16, each from 16:00 to 18:00 UTC, affected runs fail with an error annotation and the workflows do not start. Afterwards, evaluate mode with warnings applies again.
Is upgrading to actions/checkout v7 enough?
It helps, because v7 no longer checks out fork code under these triggers by default. It does not protect against gh pr checkout, git fetch or executed artifacts, though, and it changes nothing about the block from December 2.
Your next step
Run the search from step 1 for your organization this week and put every hit into a small table with one of the four recommendations. After that you know whether November 9 affects you at all. And I am curious: did you find a workflow that really needs pull_request_target, or was it convenience all along? Let me know in the comments.
Sources
- GitHub Changelog: pull_request_target blocked by default December 2 (November 2, 2026)
- GitHub Docs: Securely using pull_request_target
- GitHub Docs: Governing execution of GitHub Actions workflows
- GitHub Docs: About Actions policies
- GitHub Docs: REST API endpoints for GitHub Actions policies
- GitHub Security Lab: Preventing pwn requests
- actions/checkout: README and action.yml (v7, allow-unsafe-pr-checkout)
- Tested with Node.js 22.23, TypeScript 7.0.2 (
tsc --noEmit), yaml 2.9.1 and the Node test runner (5 tests). The workflow files were parsed for valid YAML syntax and the action inputs were checked against each action.yml. The workflows themselves were not run on GitHub.
This post was created with AI assistance.
👉 Next step: Not sure which AWS service fits your use case? My AWS service finder asks a few questions and points you to the right one.
Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

Top comments (0)