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_requestandpull_request_targetand their access to secrets - Recognise the classic attack:
pull_request_targetplus 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.
| Event | Access to secrets | Code executed | Context |
|---|---|---|---|
pull_request | ❌ No (forks) | The pull request code | Fork |
pull_request_target | ✅ Yes | The target branch code | Base 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 testpull_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 testThe classic attack
The danger appears when you combine pull_request_target with a checkout of
the pull request code:
# ❌ DANGEROUS: access to secrets + fork codeon: 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:
- The attacker forks your repository
- They edit
package.jsonto add a malicious script totest - They open a pull request
- Your
pull_request_targetworkflow runs - The malicious script sends
$API_TOKENto 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_targeton 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 holdsid-token: writewith 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 codeon: 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 executedon: 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.
| Guard | Strength | Condition (if:) |
|---|---|---|
| Check there is no fork | Strong, no human needed | github.event.pull_request.head.repo.fork == false |
| Upstream permission job | Strong | needs.gate.outputs.allowed == 'true' |
environment plus reviewers | Strong, manual approval | (no if, a protected environment:) |
| Trusted actor | For bots | github.actor == 'dependabot[bot]' |
| Maintainer-only label | The weakest | contains(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.04When 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.04When 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.04When 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.04When 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.04When 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.
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.
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.
- 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- 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- 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 }}underpull_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).
plumber analyze --github-url https://github.com/your-org/your-repoThe 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.
scorecard --local . --checks Dangerous-Workflow --show-detailsWith Checkov
Checkov covers the same rule through the identifier CKV_GHA_1.
checkov -d .github/workflows/ --check CKV_GHA_1The pattern to search for
For a quick manual audit, two grep calls are enough to list the workflows
worth a close look.
# Look for potentially dangerous workflowsgrep -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):
# pull_request_target runs where the source differs from the base repository = a fork pull requestgh 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_requestruns in the fork context with no secrets;pull_request_targetruns in the target repository with the secrets.- The classic attack combines
pull_request_targetwith a checkout of the fork code (ref: ...head.sha); the attacker's code then runs with your secrets. - The legitimate uses of
pull_request_targetnever 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_1rule, or plumber's ISSUE-804 control. - To run privileged work on pull requests, prefer a strong guard (no fork, a permission job,
environmentplus reviewers); the label is the weakest and does not replace a review. github.repository ==protects nothing: underpull_request_target, it always holds the base repository. Tell a fork apart withhead.repo.fork.- Exploitation does not merge: the trace sits in the Actions runs (
head_repositorydiffering from the base repository). When in doubt, rotate the secrets before you even analyse.
FAQ: frequent questions
No, it is a widespread fake guard. Under pull_request_target, github.repository always holds the base repository, never the fork. A condition such as if: github.repository == 'org/repo' is therefore true for every incoming pull request, including those from a malicious fork: it blocks nothing. To actually tell a fork apart, test github.event.pull_request.head.repo.fork == false or compare head.repo.full_name with github.repository.
Exploitation does not merge: the evidence lives in the Actions runs, not in the Git history. List the pull_request_target runs and spot those whose source differs from the base repository:
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}'
If such a run belongs to a workflow that checked out the fork ref, treat the secrets as compromised: rotate them immediately and audit the logs.
No, it is the weakest guard. Only a write or triage account can add a label, which gives it some value, but adding a label is not reviewing the code: the maintainer can label without auditing the diff, and above all the fork can push a new commit after the label was added. Use it alongside a stronger guard (no fork, a permission gate job, environment plus reviewers), never on its own.
Yes, and that is precisely what makes it dangerous. Unlike pull_request (which runs in the fork context, without secrets), pull_request_target runs in the context of the base repository, with access to the secrets, even for a pull request coming from a fork. As long as the workflow does not run the fork's code, that stays under control. The risk appears as soon as you check out the fork ref (head.sha) and run its code in that privileged context.
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.