The Hidden Risk in Your CI Pipeline: Why `pull_request_target` Is a Security Liability

pull_request_target gives CI workflows full access

A Lesson the TanStack Supply Chain Attack Made Very Clear

In May 2026, a threat group executed a supply chain attack that compromised over 80 TanStack packages and more than 400 packages across the broader ecosystem. Stolen OIDC tokens from hijacked GitHub Actions runs were used to publish malicious releases — complete with valid provenance attestations — that harvested credentials from every CI/CD pipeline that ran npm install in the infection window.

TanStack published a detailed post-mortem worth reading in full. We also wrote about how pnpm 11 happened to block it as a side effect of its new security defaults. But there’s a piece of the puzzle worth addressing separately: how did the attackers get their code running inside trusted CI environments in the first place?

The answer, in many cases, is a misunderstood GitHub Actions trigger called pull_request_target.

This FAQ guide explains what it does, why it’s dangerous, and how to replace it safely.


The FAQ: Understanding the Mechanics

1. What is the fundamental difference between pull_request and pull_request_target?

Think of it as a matter of trust and context.

pull_request runs in a sandbox. It uses the code from the Pull Request branch. Because that code could come from anyone — including an external contributor with malicious intent — GitHub grants it only a read-only token and zero access to repository secrets. What you get in return is safety.

pull_request_target runs in the home context. It uses the workflow file from your base branch (e.g., main). Because the workflow itself is trusted code, GitHub grants it a read/write token and full access to secrets. What you gain in capability, you pay for in risk — if you ever let untrusted code execute under this trigger, you hand attackers the keys.


2. When I run a standard pull_request action, which branch’s code actually runs?

GitHub doesn’t simply pick one branch or the other. It creates a temporary virtual merge commit — a preview of what your repository would look like if the PR were merged into the base branch at that moment.

The workflow file is loaded from the PR branch (so contributors can test their own CI changes). The code being tested is this merged preview. This separation is what makes pull_request safe: even if the workflow file is modified, the token permissions and secret access remain restricted.


3. So when is pull_request_target actually useful?

It was designed for privileged automation on forked PRs — specifically tasks that need to interact with the parent repository but must not run untrusted code. Legitimate use cases are narrower than most teams realise:

  • Labelling or assigning a PR automatically based on which files were modified
  • Posting a bot comment on a fork’s PR (e.g., “Welcome! Here are our contribution guidelines”)
  • Moving a PR into a specific GitHub Project column
  • Triggering a downstream notification based on PR metadata

If you find yourself reaching for pull_request_target to run tests or install dependencies, stop — that’s a red flag.


4. Why exactly is pull_request_target considered dangerous?

The risk is script injection via untrusted code.

Here is the scenario that played out, in various forms, across the TanStack attack and similar incidents:

  1. An attacker submits a PR that modifies a package.json, a test helper, or a Makefile
  2. Your pull_request_target workflow checks out the PR branch (actions/checkout@v4 with ref: ${{ github.event.pull_request.head.sha }})
  3. The workflow runs npm install or make test
  4. The malicious prepare or postinstall script in the attacker’s code executes inside a runner that has your secrets in memory
  5. Your API keys, cloud credentials, and tokens are exfiltrated

The attack doesn’t require breaking any GitHub security feature. It abuses a perfectly logical one: the fact that pull_request_target has secrets because it’s supposed to. The vulnerability is using it to also run untrusted code.


5. Does a PR have access to the build cache? Could that be exploited?

Yes, with strict isolation designed to prevent cache poisoning:

  • Read access: PRs can read the cache from the main branch to speed up their builds
  • Write access: PRs using pull_request cannot overwrite the global main cache, they save only to a private, PR-scoped cache

The exception is pull_request_target, which can write to the main cache because it runs in the base repository context. This means a malicious contributor could, if your workflow is misconfigured, poison the shared cache that every developer and every future CI run reads from. It’s a subtle, high-leverage attack surface that’s easy to overlook.

How the Attack Actually Works: Poisoning via a Dependency

The most insidious variant of this attack doesn’t look like an attack at all when the PR lands. Here’s the sequence:

  1. The attacker opens a PR that adds or updates a dependency, say, a minor version bump of a utility package.

  2. The pull_request_target workflow runs npm install, which installs the attacker’s dependency. That package contains a postinstall script that writes a malicious binary or tampers with a cached build artifact, but does nothing else visibly harmful right now. Critically, because pull_request_target runs in the context of the base repository rather than the PR, the compromised node_modules or build output is saved directly into the shared main branch cache under a cache key such as npm. No review, no approval, no merge required. The cache is already poisoned the moment the workflow runs.

  3. The cache is already poisoned. The attacker doesn’t need to do anything else, no follow-up, no social engineering, no waiting for a merge. Simply opening the PR and triggering the workflow automatically was enough.

  4. A legitimate developer opens a completely unrelated PR on the main branch. Their workflow runs, hits the same cache key, and restores the poisoned cache without reinstalling anything because the lockfile hash matches and the cache appears valid.

  5. The payload executes during the legitimate build inside a trusted context, with full access to secrets, on code that was never compromised. The attack has successfully jumped from a rejected external PR into a trusted internal workflow.

This is what makes cache poisoning so dangerous compared to direct credential theft: there is a time delay and a trust gap between the moment the cache is written and the moment it is consumed. The malicious PR can be closed, the contributor can disappear, and the damage is already done, sitting quietly in the cache, waiting for the next legitimate run to detonate it.

The TanStack connection: The TanStack incidents were primarily driven by cache poisoning. Attackers exploited misconfigured pull_request_target workflows to write malicious artifacts into the shared build cache. These artifacts were later restored in trusted runs, allowing the attack to bypass branch protections and exfiltrate the secrets needed to publish compromised packages.


6. What should I use instead?

The recommended pattern is a “Trusted Bridge”, two separate workflows that cleanly separate untrusted execution from privileged reporting.

Step 1: The Untrusted Worker (pull_request)

This workflow runs your tests using only the read-only token. It has no secrets. At the end, it saves the results as a GitHub Actions artifact.

on: pull_request

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm test > results.json
      - uses: actions/upload-artifact@v4
        with:
          name: test-results
          path: results.json

Step 2: The Trusted Reporter (workflow_run)

This workflow triggers only after the first one finishes. It runs in your main branch context, downloads the artifact, and uses its secrets to post results without ever touching the PR’s code.

on:
  workflow_run:
    workflows: ["Unit Tests"]
    types: [completed]

jobs:
  report:
    if: ${{ github.event.workflow_run.conclusion == 'success' }}
    runs-on: ubuntu-latest
    steps:
      - name: Download and Comment
        uses: actions/github-script@v7
        with:
          script: |
            const prNumber = context.payload.workflow_run.pull_requests[0].number;
            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: prNumber,
              body: '✅ Build successful!'
            });

The key insight: the trusted reporter never checks out any PR branch code. It only reads a structured artifact produced by the sandboxed runner. There is no path for injected scripts to reach secrets.


Best Practices Summary

Default to pull_request. Use this trigger for 95% of your CI, testing, linting, building, type checking. The read-only token and secret isolation are the right defaults for code that hasn’t been reviewed yet.

Never check out PR code in pull_request_target. If you use pull_request_target at all, restrict it strictly to metadata operations: labels, comments, project board moves. Never combine it with actions/checkout pointing at the PR branch.

Use workflow_run for privileged reporting. Any time a test result needs to be posted to a dashboard, uploaded to an external service, or commented on a PR with a token that has write permissions, use the Trusted Bridge pattern above.

Pin your actions to a commit SHA. Tags like @v4 are mutable. An attacker who compromises an action’s repository can silently update what @v4 points to. Pin to an immutable SHA instead:

- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

Minimise token permissions explicitly. Use the permissions block in your workflow YAML to set the GITHUB_TOKEN to the minimum required scope. A workflow that only needs to read code should say so:

permissions:
  contents: read

Audit your existing workflows now. Search your .github/workflows/ directory for pull_request_target. For each result, ask: does this workflow check out any code from the PR branch? If yes, it is a potential credential exfiltration vector.


Connecting the Dots: CI Hygiene and Supply Chain Security

The TanStack attack was a reminder that supply chain security isn’t just about what you install — it’s about the entire pipeline that builds, tests, and publishes your software. A misconfigured pull_request_target workflow is one of the most direct ways an external contributor can gain privileged access to your infrastructure, without exploiting any zero-day, without breaking any platform feature.

For a deeper look at how to harden your dependency installation step against attacks like Mini Shai-Hulud, including the pnpm 11 defaults that stopped it cold, read our companion article: Why I’m Glad I Upgraded to pnpm 11 Before the TanStack Attack.

For the authoritative reference on all GitHub Actions event triggers and their security implications, see the GitHub Actions documentation on events that trigger workflows.


Edit May 13th: See the Tanstack Blog post what they think about Hardening TanStack After the npm Compromise.


Concerned about the security posture of your CI/CD pipelines? Webmobix Solutions AG helps engineering teams audit, harden, and modernise their DevOps infrastructure. Get in touch to discuss how we can help.