pull_request_target Without Regret: Secure Fork PRs in GitHub Actions
If you maintain a public repo, you eventually hit this tradeoff:
- You want CI + automation on contributions from forks.
- You don’t want to leak secrets or run untrusted code with elevated permissions.
A lot of teams switch to pull_request_target to get access to secrets and write permissions (for labels/comments), then accidentally check out and execute fork code in the same job. That’s one of the fastest ways to create a supply-chain incident in your own repo.
In this post, I’ll show a safer pattern I use in real repos:
- Split untrusted validation from trusted automation.
- Avoid checking out attacker-controlled code in privileged contexts.
- Use explicit permissions and OIDC (where relevant).
- Add guardrails so regressions are caught automatically.
Why pull_request_target is risky
pull_request_target runs in the context of the base repository, not the fork. That means it can access repo secrets and can get elevated GITHUB_TOKEN permissions.
That’s useful for trusted tasks like labeling, but dangerous if your workflow does this:
on:
pull_request_target:
types: [opened, synchronize]
jobs:
bad-example:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: npm ci && npm test
This checks out untrusted fork code and executes it inside a privileged event context.
Even if you think “it’s just tests,” test scripts can run arbitrary shell commands, exfiltrate tokens, or abuse write permissions.
The safer architecture: two workflows
Use:
-
pull_requestfor untrusted code execution (no secrets, minimal token permissions) -
pull_request_targetfor trusted metadata actions only (labels, comments, triage)
Workflow A: untrusted CI (pull_request)
This workflow can build/test fork code, but should have minimal permissions and no secrets.
# .github/workflows/pr-ci.yml
name: PR CI (untrusted)
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout PR code
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- name: Install deps
run: npm ci
- name: Run tests
run: npm test -- --ci
Key point: this is where untrusted code runs.
Workflow B: trusted repo automation (pull_request_target)
This workflow should not execute PR code. It should work only with event metadata.
# .github/workflows/pr-triage.yml
name: PR Triage (trusted)
on:
pull_request_target:
types: [opened, reopened]
permissions:
contents: read
pull-requests: write
jobs:
label-size:
runs-on: ubuntu-latest
steps:
- name: Add size label based on files changed
uses: ollieb89/pr-size-labeler@v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
No checkout of head.sha, no running contributor scripts, no package install from the PR branch.
If you must run privileged follow-up after CI
Sometimes you need a trusted action after untrusted checks pass (for example, posting a summary comment or syncing metadata). Use workflow_run as a boundary.
# .github/workflows/pr-post-ci.yml
name: PR Post-CI (trusted follow-up)
on:
workflow_run:
workflows: ["PR CI (untrusted)"]
types: [completed]
permissions:
contents: read
pull-requests: write
jobs:
comment:
if: >-
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- name: Comment on PR safely
uses: actions/github-script@v7
with:
script: |
const run = context.payload.workflow_run;
const prs = run.pull_requests || [];
if (!prs.length) return;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prs[0].number,
body: '✅ CI passed. Maintainers can review safely.'
});
This avoids executing untrusted code in the trusted phase.
Permission hardening checklist
Even with split workflows, tighten defaults:
-
Set explicit
permissionsin every workflow (don’t rely on defaults). - Prefer
contents: readand add write scopes only when required. - Avoid long-lived cloud keys in secrets; prefer OIDC short-lived credentials.
- Pin third-party actions to a commit SHA where possible.
- Restrict
pull_request_targetworkflows to metadata operations.
Example of pinning:
- name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
Catch bad patterns automatically
This is where linting/policy checks help. You want CI to fail if someone introduces dangerous patterns like:
-
pull_request_target+ checkout ofgithub.event.pull_request.head.sha - broad write permissions in untrusted workflows
- unpinned external actions
A practical approach is to add a dedicated workflow policy step.
name: Workflow Policy
on:
pull_request:
paths:
- '.github/workflows/**'
permissions:
contents: read
jobs:
policy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate workflow security
uses: ollieb89/workflow-guardian@v1
with:
fail_on: high
If your org has custom requirements, encode them once and enforce them on every PR instead of relying on manual review memory.
Common “almost safe” mistakes
I still see these in otherwise good repos:
-
Using
pull_request_targetfor tests because “we needed secrets quickly.” -
Checking out
head.shain trusted workflows to parse files. - Default token permissions left too broad.
- Comment bots with repo write access that also run shell commands from PR input.
If you recognize one of these, the fix is usually architectural, not just another if: condition.
A practical baseline you can adopt today
If you only do three things this week, do these:
- Move all fork code execution to
pull_request. - Keep
pull_request_targetmetadata-only. - Add a workflow policy check to prevent regressions.
That gives you a strong baseline without slowing contributor velocity.
Toolkit links
If you want to implement this pattern quickly, these are the actions I use:
- workflow-guardian (workflow policy/security checks): https://github.com/marketplace/actions/workflow-guardian
- pr-size-labeler (safe PR size labeling): https://github.com/ollieb89/pr-size-labeler
- stale-branch-cleaner: https://github.com/ollieb89/stale-branch-cleaner
- changelog-generator: https://github.com/ollieb89/changelog-generator
- test-results-reporter: https://github.com/ollieb89/test-results-reporter
If you’re maintaining public repos, treat workflow design like production code. The event model and permission boundaries matter as much as the scripts themselves.
Top comments (0)