Skip to content
Français
CI/CD & Automatisation medium

Securing pull_request_target

40 min de lecture

Read this page in French

The pull_request_target event is one of the most dangerous in GitHub Actions. It runs code with access to the secrets, even for pull requests coming from forks. Used wrongly, it opens the door to the exfiltration of your credentials.

What you will learn

  • Tell apart pull_request and pull_request_target and their access to secrets
  • Recognise the classic attack: pull_request_target plus a checkout of the fork
  • Choose the right anti-fork guard, from the strongest to the weakest
  • Avoid the trap of the fake github.repository == guard
  • Apply the two-workflow pattern joined by workflow_run
  • Detect vulnerable workflows and check whether you have already been exploited

The difference between pull_request and pull_request_target

Both triggers react to a pull request, but their security model is the opposite of one another. Understanding that difference underpins everything else.

EventAccess to secretsCode executedContext
pull_request❌ No (forks)The pull request codeFork
pull_request_target✅ YesThe target branch codeBase repository

pull_request (safe by default)

With pull_request, the workflow runs in the context of the fork: it sees the pull request code but has no access to the secrets. That is the safe default behaviour.

on:
pull_request:
types: [opened, synchronize]
jobs:
test:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# The code comes from the pull request (possibly a fork)
# No access to secrets for forks = safe
- run: npm test

pull_request_target (dangerous)

With pull_request_target, the workflow runs in the context of the target repository: it has access to the secrets. As long as it does not check out the fork code, that stays under control.

on:
pull_request_target:
types: [opened, synchronize]
jobs:
test:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# The code comes from the target branch (main)
# Access to secrets = fine as long as the fork code is not checked out
- run: npm test

The classic attack

The danger appears when you combine pull_request_target with a checkout of the pull request code:

# ❌ DANGEROUS: access to secrets + fork code
on:
pull_request_target:
jobs:
build:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }} # Fork code!
# This script comes from the fork and has access to the secrets
- run: npm test
env:
API_TOKEN: ${{ secrets.API_TOKEN }} # Exfiltratable!

The attack scenario:

  1. The attacker forks your repository
  2. They edit package.json to add a malicious script to test
  3. They open a pull request
  4. Your pull_request_target workflow runs
  5. The malicious script sends $API_TOKEN to an external server

What makes exploitation worse

A leaked secret is already serious. Three factors turn it into a far wider incident.

  • Self-hosted runner: a vulnerable pull_request_target on a self-hosted runner does not only leak a secret, it grants arbitrary code execution on your infrastructure. The attacker can persist on the machine, pivot into the internal network and contaminate subsequent runs. That is RCE, not a mere leak.
  • OIDC (id-token: write): if the job holds id-token: write with no guard, the attacker obtains an OIDC token into your cloud. That is worse than a repository secret: they inherit the associated IAM roles and pivot straight into your cloud account.
  • An unpinned third-party action: inside the privileged job, an action referenced by tag (@v4) or an image on a mutable tag is the second step of the escalation. Pin every action by SHA (uses: org/action@<sha>) and every image by digest (image@sha256:...).

Legitimate use cases

The trigger is not to be banned outright: it has legitimate uses, all sharing one property, they never run the fork's code.

1. Automatic labelling (with no fork checkout)

Labelling a pull request according to the files it touches needs only the GitHub API, never the fork's code.

# ✅ Safe: no checkout of the fork code
on:
pull_request_target:
types: [opened]
permissions: {}
jobs:
label:
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}

2. Commenting on pull requests

Posting a welcome message goes through actions/github-script, which calls the GitHub API without cloning anything.

# ✅ Safe: no fork code executed
on:
pull_request_target:
types: [opened]
permissions: {}
jobs:
welcome:
runs-on: ubuntu-24.04
permissions:
pull-requests: write
steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: 'Thanks for your contribution!'
})

The anti-fork guards, by strength

When a pull_request_target really must run on incoming pull requests, a guard decides which of them may run with the secrets. They are not equal. Here are the five guards, from the strongest to the weakest.

GuardStrengthCondition (if:)
Check there is no forkStrong, no human neededgithub.event.pull_request.head.repo.fork == false
Upstream permission jobStrongneeds.gate.outputs.allowed == 'true'
environment plus reviewersStrong, manual approval(no if, a protected environment:)
Trusted actorFor botsgithub.actor == 'dependabot[bot]'
Maintainer-only labelThe weakestcontains(github.event.pull_request.labels.*.name, 'safe-to-test')

1. Check that the pull request does not come from a fork (strong)

Run nothing privileged if the pull request comes from a fork. This is the cleanest guard, and it needs no human intervention.

jobs:
privileged:
if: github.event.pull_request.head.repo.fork == false
runs-on: ubuntu-24.04

When to use it: whenever the privileged job only makes sense for internal pull requests (branches of the repository itself).

2. An upstream permission check job (strong)

A first job checks that the author holds a write role, and the privileged job depends on it (needs) in fail-closed mode: if the check fails, the second one does not run.

jobs:
gate:
runs-on: ubuntu-24.04
outputs:
allowed: ${{ steps.check.outputs.require-result }}
steps:
- uses: actions-cool/check-user-permission@c21884f3dda18dafc2f8b402fe807ccc9ec1aa5e # v2.4.0
id: check
with:
require: write
privileged:
needs: gate
if: needs.gate.outputs.allowed == 'true'
runs-on: ubuntu-24.04

When to use it: when trusted contributors (beyond bots) need to be able to trigger the job.

3. An environment with required reviewers (strong, manual approval)

Attach the job to an environment protected by required reviewers. GitHub pauses the job until a human approves it.

jobs:
privileged:
environment: fork-pr # protected by required reviewers
runs-on: ubuntu-24.04

When to use it: when a human must review the pull request before any privileged execution.

4. A trusted actor (for bots)

Allow only one specific actor, typically a known bot such as Dependabot.

jobs:
privileged:
if: github.actor == 'dependabot[bot]'
runs-on: ubuntu-24.04

When to use it: for automated pull requests from an identified bot. Combine it with another guard; github.actor on its own is an identity condition, not a review.

5. A maintainer-only label (the weakest)

Run only if a label has been added to the pull request.

jobs:
privileged:
if: contains(github.event.pull_request.labels.*.name, 'safe-to-test')
runs-on: ubuntu-24.04

When to use it: as a complement, never alone. Only a write or triage account can add a label, but adding a label is not reviewing the code: the maintainer can label without auditing the diff, and the fork can push a new commit after the label.

The safe pattern: a workflow in two parts

If you must run the fork's code and have access to the secrets, split the work into two separate workflows.

Workflow 1: build without secrets

The first workflow triggers on pull_request: it builds the fork's code but has no secret to steal.

.github/workflows/pr-build.yml
name: PR Build
on:
pull_request: # No secrets, fork code
permissions: {}
jobs:
build:
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- name: Check out the pull request code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Build the project
run: npm ci && npm run build
- name: Upload the build artefact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: build-output
path: dist/

Workflow 2: deployment with secrets

The second triggers on workflow_run, once the build is finished: it holds the secrets but only handles the artefact, never the fork's source code.

.github/workflows/pr-deploy-preview.yml
name: Deploy Preview
on:
workflow_run:
workflows: ["PR Build"]
types: [completed]
permissions: {}
jobs:
deploy:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-24.04
permissions:
actions: read # Download the artefact from the other run
pull-requests: write # Comment the preview URL
steps:
# Download the artefact (not the source code)
- name: Fetch the build artefact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: build-output
path: dist/
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
# Deploy with the secrets
- name: Deploy the preview
run: ./deploy-preview.sh
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

Why this is safe:

  • The first workflow runs the fork's code, but with no access to the secrets
  • The second workflow has access to the secrets, but only downloads the artefact (not the fork's source code)
  • The artefact cannot contain executable code (only the built files)

The security rules

In short, the safety of pull_request_target rests on one rule, never run the fork's code with the secrets, and on three safe patterns.

❌ Never do this

The deadly combination: a checkout of the fork code and secrets exposed in the same job.

on: pull_request_target
jobs:
build:
runs-on: ubuntu-24.04
steps:
# Checkout of the fork code
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}
# Running fork scripts with the secrets
- run: ./scripts/build.sh
env:
SECRET: ${{ secrets.SECRET }}

✅ Safe patterns

Three patterns cover every real need without ever running the fork's code with the secrets.

  1. No checkout of the fork:
on: pull_request_target
jobs:
label:
runs-on: ubuntu-24.04
steps:
# GitHub actions only, no fork code
- uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
  1. Checkout of the target branch only:
on: pull_request_target
jobs:
analyze:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# No ref = checkout of the target branch (main)
- run: ./trusted-script.sh # A script from main, not from the fork
  1. Splitting build and deploy through workflow_run:
on:
workflow_run:
workflows: ["Untrusted Build"]
types: [completed]

The checkout v7 safety net

Good news from mid-2026: actions/checkout@v7 finally adds a default protection against the pwn request. When a pull_request_target workflow (or certain workflow_run ones) tries to check out the fork ref (the pull request branch or SHA), checkout now refuses the operation instead of pulling untrusted code into a runner holding elevated permissions.

What to remember:

  • The dangerous pattern (ref: ${{ github.event.pull_request.head.sha }} under pull_request_target) now fails by default. That is exactly the anti-pattern described above, blocked natively.
  • A new input, allow-unsafe-pr-checkout, explicitly restores the old behaviour. Treat it as a sensitive exception: justify it, isolate it and review it, never enable it out of convenience.
  • The change was backported on 16 July 2026 to the supported major versions; the floating tags (@v4, @v3) inherit the protection automatically.

Source: the Socket analysis of checkout blocking pull_request_target checkouts.

Detecting vulnerable workflows

Rather than auditing by eye, several scanners spot dangerous pull_request_target usage automatically.

With plumber

plumber, the CI/CD pipeline scanner, reports this pattern through control ISSUE-804 (untrusted code checked out under pull_request_target).

Fenêtre de terminal
plumber analyze --github-url https://github.com/your-org/your-repo

The detail of the control and its remediation are documented here: ISSUE-804 in the plumber documentation.

With Backflow

Where plumber spots the smell (the risky pattern in the YAML), Backflow proves the path: it follows the data flow from the attacker input (the checked-out fork ref) to the exfiltrated secret, unrolled step by step. The distinction is useful in practice: plumber tells you where to look, Backflow confirms that an exploitable path really exists and lets you prioritise remediation on the workflows that genuinely reach a secret, rather than on false positives.

With Scorecard

The OpenSSF Scorecard Dangerous-Workflow check reports precisely this pattern.

Fenêtre de terminal
scorecard --local . --checks Dangerous-Workflow --show-details

With Checkov

Checkov covers the same rule through the identifier CKV_GHA_1.

Fenêtre de terminal
checkov -d .github/workflows/ --check CKV_GHA_1

The pattern to search for

For a quick manual audit, two grep calls are enough to list the workflows worth a close look.

Fenêtre de terminal
# Look for potentially dangerous workflows
grep -r "pull_request_target" .github/workflows/
grep -r "github.event.pull_request.head" .github/workflows/

Have I already been exploited?

A crucial point often ignored: exploitation leaves no trace in the repository. A vulnerable pull_request_target runs at pull request time, with no merge: the evidence sits in the Actions runs, not in the Git history. Looking for a suspicious commit is therefore pointless.

The right question is: has a fork ever triggered my privileged workflow? Under pull_request_target, it is enough to compare the source repository (head_repository) with the base repository (repository) of every run. The GitHub API exposes both fields (unlike gh run list, which does not return them):

Fenêtre de terminal
# pull_request_target runs where the source differs from the base repository = a fork pull request
gh api "repos/YOUR_ORG/YOUR_REPO/actions/runs?event=pull_request_target&per_page=100" \
--jq '.workflow_runs[]
| select(.head_repository.full_name != .repository.full_name)
| {id, fork: .head_repository.full_name, date: .created_at, workflow: .name}'

A fork different from the base repository means fork code ran with your secrets. If such a run belongs to a workflow that checked out the fork ref (head.sha), treat the secrets as compromised: rotate them immediately, then audit the logs of that run (outbound network connections, environment variables printed, unusual commands).

Key points

  • pull_request runs in the fork context with no secrets; pull_request_target runs in the target repository with the secrets.
  • The classic attack combines pull_request_target with a checkout of the fork code (ref: ...head.sha); the attacker's code then runs with your secrets.
  • The legitimate uses of pull_request_target never run the fork's code: labelling and comments go through the GitHub API alone.
  • To build a fork's code and use secrets, split into two workflows joined by workflow_run, the second consuming only an artefact.
  • Audit with Scorecard's Dangerous-Workflow check, Checkov's CKV_GHA_1 rule, or plumber's ISSUE-804 control.
  • To run privileged work on pull requests, prefer a strong guard (no fork, a permission job, environment plus reviewers); the label is the weakest and does not replace a review.
  • github.repository == protects nothing: under pull_request_target, it always holds the base repository. Tell a fork apart with head.repo.fork.
  • Exploitation does not merge: the trace sits in the Actions runs (head_repository differing from the base repository). When in doubt, rotate the secrets before you even analyse.

FAQ: frequent questions

Next steps

  • OIDC for AWS, Azure and GCP: restricting through a trust policy what a workflow can reach in the cloud, even once compromised.
  • Security checklist: checking that no other trigger in the repository offers the same privileges.
  • Pinning actions by SHA: closing the second step of the escalation, the unpinned third-party action inside the privileged job.

Is this site useful to you?

Fewer than 1% of readers support this site.

I maintain more than 700 free guides, with no ads and no tracking. Any support, even a symbolic one, helps cover hosting and keeps these resources free. Thank you for the help.

The form does not show? Open Ko-fi in a new tab.

Subscribe and follow my DevSecOps work on LinkedIn