3 Ways to Secure Your GitHub Workflows from Malicious Pull RequestsDon't get hacked. Implement these best practices today to safely handle pull requests from forks without disabling your automations.

The "Pull Request Nightmare" You're Probably Ignoring

Let's be brutally honest: GitHub Actions is one of the best things to happen to developer workflow automation, but it might also be a wide-open backdoor into your repository. We all love the magic of opening a pull request and watching a fleet of automated tests and checks spin up instantly. It's the core of modern CI/CD. But here's the nightmare scenario: on any public repository, anyone on the internet can open a pull request. This is the entire point of open source, but it carries a terrifying risk. If your workflows are misconfigured, you aren't just testing their code—you're executing their code, in your environment, with your secrets. An attacker doesn't need to find a complex 0-day; they just need to find a repo that made a simple, common, and catastrophic mistake.

The culprit is a deceptively simple trigger: pull_request_target. It looks almost identical to its safe cousin, pull_request. But while pull_request runs in the context of the fork with zero access to your secrets (as it should), pull_request_target runs in the context of your base repository. This means it has full access to your GITHUB_TOKEN (which often has write permissions) and any other production, staging, or API keys you've stored in your repo's secrets. This trigger exists for a legitimate reason—to allow actions to label PRs or post comments, which requires write access to the base repo. The problem, as security researchers at Orca Security (roin-orca) famously demonstrated in their "Pull Request Nightmare" series, is when developers combine this trigger with a command that checks out the untrusted code from the pull request. This one-two punch turns your CI pipeline into a remote code execution (RCE) engine for any attacker who bothers to open a PR.

The Original Sin: Never, Ever Check Out Untrusted Code

Before we even get to the three primary solutions, we need to establish the cardinal sin of GitHub Actions security. If you take nothing else away from this, burn this into your brain: never use pull_request_target to check out and run code from a pull request. The entire vulnerability hinges on this single, fatal action. The moment your workflow file contains both on: pull_request_target and a step like uses: actions/checkout@v4 with ref: ${{ github.event.pull_request.head.sha }}, you have created a vulnerability. You are explicitly telling GitHub, "Please download the code from this random person's fork—code I have not reviewed—and run it on a machine that has my secret keys." It's the digital equivalent of finding a random USB stick in the parking lot and plugging it directly into your production database server.

The fix for this specific anti-pattern is simple: just don't do it. If your workflow's only job is to label a PR based on file paths, it doesn't need to check out the code. It can get all the metadata it needs from the github.event context. Actions like actions/labeler are designed to work this way and are safe. The problem is when developers create custom workflows for testing, building, or deploying, and they choose pull_request_target because they need secrets (like a cloud provider key) or write access to post results. They want to test the PR's code, so they check it out. And boom—game over. If you absolutely must run a privileged workflow against PR code, you must use one of the following three gating mechanisms.

Way 1: Check the Actor and Repo Origin (The "Trust, but Verify" Gate)

The simplest and most effective first line of defense is to treat pull requests differently based on who sent them. Not all PRs are equal. A pull request from a core maintainer working in a branch on the main repository is fundamentally different from a pull request submitted by a brand-new account from a fork. We can, and should, enforce this distinction in our workflow. You can add a simple if condition to your high-risk jobs that checks where the code is coming from. If it's from an internal branch, run the job. If it's from an external fork, skip it.

This logic is surprisingly easy to implement. You can add a condition to your job that checks if the pull request's "head" repository (the source) is the same as the "base" repository (the target). If they match, you're safe—it's not a fork. This stops 99% of drive-by attacks from malicious forks instantly.

name: CI-CD Pipeline
on: pull_request_target

jobs:
  build-and-test:
    # THIS IS THE MAGIC LINE
    if: github.event.pull_request.head.repo.full_name == github.repository
    runs-on: ubuntu-latest
    steps:
      - name: Checkout PR Code
        # This is now "safe" because it only runs for internal PRs
        uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}

      - name: Run privileged build
        run: echo "Running build with secrets..."
        env:
          MY_SECRET_KEY: ${{ secrets.MY_SECRET_KEY }}

This is a fantastic, low-friction solution for most repositories. The privileged jobs simply don't run for external contributors. The downside, of course, is that... well, the privileged jobs don't run for external contributors. This might be fine for a closed-source project, but for a major open-source project, you need to run tests on external PRs. If you block all forks, you're just pushing the testing burden back onto your maintainers, who will have to pull the code down and run it locally (which is also a security risk!). This method is a great "security-on" switch, but it damages the open-source collaboration workflow.

Way 2: Use Manual Labels as an "Approval" Check (The Human-in-the-Loop)

This is the gold standard for public open-source projects. You want to test code from forks, but only after a human maintainer has personally reviewed the code and deemed it safe. The idea is to create a workflow that is "opt-in" by a trusted user. Instead of triggering the job on pull_request_target: [opened], you trigger it on pull_request_target: [labeled]. This means the workflow does absolutely nothing when the PR is first opened. It only fires after a maintainer has added a specific label, like safe-to-test or run-ci.

This creates a powerful two-part security lock. First, you need to protect your repository settings so that only maintainers (not the PR author) can add labels. Second, you add a condition to your workflow job that only proceeds if the specific "safe" label was the one just added. This prevents the workflow from running if someone adds a different, unrelated label like bug or documentation. The maintainer's workflow becomes:

  1. Attacker opens a PR.
  2. Maintainer gets a notification.
  3. Maintainer thoroughly reviews every single line of code in the PR.
  4. If and only if it's safe, the maintainer adds the safe-to-test label.
  5. GitHub Actions then safely triggers the workflow, checks out the code, and runs the tests with secrets.
name: Forked PR CI
on:
  pull_request_target:
    types: [labeled] # Only run when a label is added

jobs:
  run-tests-on-safe-pr:
    # Check that the PR is from a fork AND has the magic label
    if: >-
      github.event.pull_request.head.repo.full_name != github.repository &&
      contains(github.event.pull_request.labels.*.name, 'safe-to-test')
    runs-on: ubuntu-latest
    steps:
      - name: Checkout PR Code
        uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}

      - name: Run integration tests
        run: echo "Running tests with cloud secrets..."
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

This approach is the best of both worlds: it allows you to test external contributions, but only after explicit human sign-off. The burden is on the maintainer to perform a meticulous security review, but that's exactly what a maintainer's job is. You are transforming an implicit, dangerous, and automated risk into an explicit, human-gated, and safe workflow.

Way 3: Gate Privileged Jobs with Environments (The "Are You Sure?" Button)

If the label-based approach feels a bit "hacky," GitHub provides a built-in, enterprise-grade feature for this exact problem: Environments. You can define a deployment environment in your repository settings (e.g., "Staging-Test" or "Privileged-CI"). These environments can then be protected with rules. The most powerful rule is "Required reviewers." You can specify a person or a team that must manually approve any workflow job that attempts to run in that environment. This is the ultimate "are you sure?" button, integrated directly into the Actions UI.

Here's the flow: You identify the job in your workflow that needs secrets (e.g., build-and-push-docker-image). In your repository settings, you create an environment called docker-registry. You add your DOCKER_USERNAME and DOCKER_PASSWORD as environment secrets (not repository secrets). Then, you add a protection rule to this environment, "Required reviewers," and add your maintainers team. Finally, you add a single line to your workflow job: environment: docker-registry. Now, when an external contributor opens a PR, the workflow will run. When it hits the build-and-push-docker-image job, it will automatically pause and enter a "Waiting" state. All the designated reviewers will get a notification from GitHub. They can then review the PR code, and if it's safe, click a big "Approve" button right in the GitHub UI. The job then resumes, gains access to the environment secrets, and finishes its work.

name: Deploy PR to Staging
on: pull_request

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Build
        run: echo "Building..."
        # This job has no secrets, it's safe

  deploy-to-staging:
    needs: build
    # This job is gated by the environment
    environment: staging-test
    runs-on: ubuntu-latest
    steps:
      - name: Deploy
        run: echo "Deploying to staging with staging secrets..."
        env:
          STAGING_API_KEY: ${{ secrets.STAGING_API_KEY }}
          # This secret is only available because the 'staging-test'
          # environment was approved by a maintainer.

This method is arguably the most secure and "correct" way to handle this. It works with any trigger (even the safer pull_request) because the security gate isn't on the trigger itself, but on the job's access to the environment. It combines the automation of CI with the non-negotiable security of human sign-off right at the point of privilege escalation. If an attacker submits a malicious PR, the workflow simply pauses and waits for an approval that will never come. The attack is stopped dead in its tracks, and the maintainers are alerted.

Conclusion: Don't Be the Next Headline

The convenience of GitHub Actions has lulled us into a false sense of security. The "Pull Request Nightmare" vulnerability is not a theoretical academic problem; it's a real, documented exploit class that has been used to attack major projects at companies like Google and Microsoft. The pull_request_target trigger is a footgun, plain and simple, and using it to check out untrusted code is an unforced error that can lead to a catastrophic breach. Your GITHUB_TOKEN can be used to push malicious code to your main branch, your production cloud keys can be stolen to mine crypto or exfiltrate user data, and your package-publishing tokens can be hijacked to launch a supply chain attack against all your users.

The responsibility is on us, the developers and maintainers, to fix this. Ignorance is not an excuse. You have three excellent, robust solutions to choose from. For a quick and dirty fix on a private repo, use the origin check (if: github.event.pull_request.head.repo.full_name == github.repository). For a public open-source project that needs to test forks, implement the manual label gate (on: pull_request_target: [labeled]). And for the most robust, built-in, and auditable solution, protect your privileged jobs using GitHub Environments with required reviewers. Go to your .github/workflows directory right now. Audit every single file that uses pull_request_target. Apply one of these fixes. Your future self, who isn't writing a post-mortem about how all your secrets were stolen, will thank you.