DEV Community

Olivier Buitelaar
Olivier Buitelaar

Posted on

`pull_request_target` Without Regret: Secure Fork PRs in GitHub Actions

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:

  1. Split untrusted validation from trusted automation.
  2. Avoid checking out attacker-controlled code in privileged contexts.
  3. Use explicit permissions and OIDC (where relevant).
  4. 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
Enter fullscreen mode Exit fullscreen mode

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_request for untrusted code execution (no secrets, minimal token permissions)
  • pull_request_target for 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
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

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.'
            });
Enter fullscreen mode Exit fullscreen mode

This avoids executing untrusted code in the trusted phase.


Permission hardening checklist

Even with split workflows, tighten defaults:

  1. Set explicit permissions in every workflow (don’t rely on defaults).
  2. Prefer contents: read and add write scopes only when required.
  3. Avoid long-lived cloud keys in secrets; prefer OIDC short-lived credentials.
  4. Pin third-party actions to a commit SHA where possible.
  5. Restrict pull_request_target workflows to metadata operations.

Example of pinning:

- name: Checkout
  uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
Enter fullscreen mode Exit fullscreen mode

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 of github.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
Enter fullscreen mode Exit fullscreen mode

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_target for tests because “we needed secrets quickly.”
  • Checking out head.sha in 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:

  1. Move all fork code execution to pull_request.
  2. Keep pull_request_target metadata-only.
  3. 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:

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)