How does pull_request_target differ from pull_request in GitHub Actions?
answer
- Same event, different trust context
- Whose copy of the workflow file runs?
- Fork builds get no secrets for a reason
- The default checkout ref is not the contributor's code
- Privilege plus untrusted code in one job
basics
~20 spull_request_target runs the base branch's workflow file in the base repository's context, so repository secrets and a writable GITHUB_TOKEN are available even for fork pull requests. pull_request runs from a fork with no secrets and a read-only token.
solid answer
~40 sBoth fire on pull-request activity, but they differ in **whose context the run executes in**. `pull_request` from a fork is untrusted: secrets are not passed, `GITHUB_TOKEN` is read-only, and the checked-out code is the contributor's. `pull_request_target` runs the workflow file **as it exists on the base branch**, in the base repository's context, with full repository secrets and a read/write token — and `actions/checkout` defaults to the base ref, not the pull request's head. That combination is why it exists (labelling, commenting, first-time-contributor greetings need write access) and why it is dangerous: explicitly checking out `github.event.pull_request.head.sha` and then running anything from that tree — a build, a package install with lifecycle scripts, a linter config — executes attacker-controlled code with your secrets in scope. Default activity types for both are `opened`, `synchronize` and `reopened`.
code
yaml · 11 lines# DANGEROUS: base-repo secrets in scope while executing the contributor's tree.
on: pull_request_target
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: npm ci && npm run build # runs attacker-controlled scriptsgo deeper
Recall that a fork's pull_request run gets no repository secrets and a read-only token, and that pull_request_target is the privileged variant. You are not expected to design around it yet.
Explain the mechanics: which copy of the workflow file executes, which ref actions/checkout takes by default under each event, and that declaring types: replaces the opened/synchronize/reopened default.
Demonstrate that you can spot the exploit in a diff — head checkout plus a build step under pull_request_target — and propose the fix, either dropping the head checkout or splitting the work across pull_request and workflow_run.
Own the policy: which workflows in the org are allowed to use pull_request_target at all, how that is reviewed and detected, and what the standard safe template for external-contribution CI looks like.
## Two events, one difference that matters `pull_request` and `pull_request_target` fire on the same activity — a pull request opened, updated, reopened, labelled, closed. The difference is entirely about **trust context**: | | `pull_request` (from a fork) | `pull_request_target` | |---|---|---| | Workflow file used | the pull request's head | the **base** branch | | Default checkout ref | the pull request's merge ref | the **base** branch | | Repository secrets | not available | available | | `GITHUB_TOKEN` | read-only | read/write (subject to `permissions`) | For a pull request from a *branch in the same repository*, `pull_request` already runs with secrets and a writable token, because the contributor is already trusted with write access. The asymmetry only appears for forks — and forks are exactly where public repositories live. ## Why pull_request_target exists A maintainer workflow often needs to *write* something in response to a fork's pull request: apply a label, post a review comment, assign a reviewer, greet a first-time contributor, update a project board. Under `pull_request` from a fork the token cannot do any of that. `pull_request_target` solves it by running the trusted, already-merged workflow definition from the base branch with the repository's own credentials. A contributor cannot change that workflow file in their pull request and have the change take effect — the base branch's copy is what runs, which is precisely the property that makes the event safe *when used as designed*. ## The dangerous pattern The trap is that `pull_request_target` gives you privilege *and* the temptation to combine it with the contributor's code: ``` on: pull_request_target jobs: build: steps: - uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} # untrusted tree - run: npm ci && npm run build # attacker's scripts ``` Everything in that tree is written by whoever opened the pull request: `package.json` lifecycle scripts, the build script itself, a Makefile, a test config, a linter plugin resolved from the repository. Running any of it executes attacker-controlled code in a job that holds your repository secrets and a writable token — and the attacker never needed write access or a review. This class of mistake has a name in the security community precisely because it was found across many well-known repositories. Secondary traps in the same family: interpolating pull-request text such as `${{ github.event.pull_request.title }}` or a branch name straight into a `run:` shell line, which injects at the shell level regardless of which event you used; and caching under `pull_request_target`, where a poisoned cache entry written by an untrusted build is later restored into a trusted run. ## Safe patterns **Do not check out head code at all.** Labelling, commenting and triaging need only the event payload and the API. This is the intended use, and it is safe by construction. **If you must touch head content, do not execute it.** Reading files for a metadata check is very different from running the project's own build. Even then, prefer a minimal, explicit `permissions:` block and no secrets beyond what the task needs. **Split into two workflows with `workflow_run`.** The untrusted half runs under `pull_request` with no secrets, builds the code, and uploads an artifact. A second workflow triggered by `workflow_run` — which, like `pull_request_target`, executes from the default branch with repository credentials — downloads that artifact and does the privileged part. The untrusted code and the secrets never share a job. Treat the downloaded artifact as data, never as something to execute. **Pass values through the environment, not through interpolation.** `env: TITLE: ${{ github.event.pull_request.title }}` followed by `run: echo "$TITLE"` avoids the shell-injection half of the problem. ## Activity types and filters Both events default to the activity types `opened`, `synchronize` and `reopened` — `synchronize` being "new commits pushed to the pull request". Declaring `types:` **replaces** that default rather than adding to it, so `types: [labeled]` means the workflow no longer runs on new commits at all, which surprises people. Both events also accept `branches`/`branches-ignore` filtering the **base** branch, and `paths`/`paths-ignore` over the diff. ## What to say in the interview The crisp answer is: same activity, different execution context — base-branch workflow file, base-repository credentials, base ref checked out by default. Then name the exploit shape (check out head, run its build, hold secrets) and the two mitigations (do not check out head; or split with `workflow_run` so untrusted code never meets a secret). Candidates who describe `pull_request_target` merely as "the one that works for forks" have used it without understanding what they granted.
- Which ref does actions/checkout@v4 check out by default under pull_request_target, and why does that matter?The **base** branch — the branch the pull request targets — not the contributor's head. That default is what makes the event safe out of the box: the job runs trusted code with trusted credentials. The danger begins only when you override it with `ref: ${{ github.event.pull_request.head.sha }}` and then execute something from that tree while secrets are in scope.
- How does the workflow_run split pattern keep fork builds safe while still allowing privileged follow-up?The first workflow runs under `pull_request` with no secrets and a read-only token, builds the untrusted code, and uploads results as an artifact. A second workflow triggered by `workflow_run` executes from the default branch with repository credentials, downloads that artifact and performs the privileged step — posting a comment, publishing a report. Untrusted code and secrets never occupy the same job, and the artifact must be treated as data, never executed.
- What happens to a pull_request workflow's triggering when you add types: [labeled]?It replaces the defaults rather than extending them. The defaults are `opened`, `synchronize` and `reopened`, so declaring only `labeled` means the workflow stops running when new commits are pushed — a common cause of "CI does not re-run after I fixed the review comments". List every type you need, including `synchronize`, whenever you override the default.
- Why is ${{ github.event.pull_request.title }} inside a run: step risky regardless of which pull-request event you used?Expressions are substituted into the shell script before it executes, so a title containing shell metacharacters becomes part of the command. Under `pull_request_target` that runs with secrets in scope; even under `pull_request` it can subvert the job. Bind the value to an `env:` variable and reference it as `"$TITLE"`, so the shell treats it as data.
saying these in an interview costs you the question
- Calls pull_request_target just the version that works for forks
- Checks out head.sha under pull_request_target and builds it
- Thinks the contributor's edited workflow file is what runs
- Assumes fork pull requests get repository secrets by default
- Interpolates pull-request text directly into a run: command