Skip to content

GitHub Actions Security: Injection, pull_request_target and Hardening

Lesson 9 of 10Advanced5 min readGitHub Actions & CI/CD · Actions SecurityVerified: Expression substitution behaviour, pull_request_target and workflow_run semantics, August 2026

Two mistakes account for most serious GitHub Actions incidents. Both come from the same root: a workflow treating data that an outside contributor controls as if it were data the maintainers wrote.

This page is about preventing both. It deliberately contains no exploit payloads — the aim is that you can recognise and fix the pattern, not reproduce it.

Why expression substitution is the whole problem

Section titled “Why expression substitution is the whole problem”

A run: block is not a script that reads variables. Actions performs textual substitution of every ${{ }} expression first, producing a finished script, and only then hands that text to the shell.

- run: echo "Reviewing ${{ github.event.pull_request.title }}"

By the time bash sees this, the title’s characters are part of the script. Whatever the contributor typed into the title field is now source code in your job — with the same access to GITHUB_TOKEN, the environment, the filesystem and any cloud credentials the job holds.

The person who wrote that line was thinking of the title as a string. The runner is treating it as program text. That gap is the vulnerability, and it does not require any unusual configuration to exist.

The fix: pass values through the environment

Section titled “The fix: pass values through the environment”
- name: Report the title
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: echo "Reviewing ${PR_TITLE}"

What it doesBinds the untrusted value to an environment variable, then references it as a shell variable inside the script.

Why we run itThe value never becomes part of the script text. It is set in the process environment by the runner, and the shell reads it as data at run time — so its contents cannot change what the script does.

Expected resultIdentical behaviour for ordinary input, and no code execution for hostile input.

Quote the expansion — "${PR_TITLE}", not $PR_TITLE — so word-splitting and glob expansion do not reintroduce surprises.

This is a two-line change, it costs nothing, and it is the single most valuable habit in this cluster. Apply it to every untrusted value, and to trusted ones too: making it unconditional means you never have to correctly classify a value under time pressure.

The same rule extends beyond run::

  • Passing to an actionwith: arguments are structured data rather than script text, so they are safer, but an action that itself shells out can reintroduce the problem. Prefer actions that document how they handle input.
  • actions/github-script — the script body is JavaScript assembled the same way. Read untrusted values from process.env, not from an interpolated expression.
  • Building a matrix from event data — a dynamic matrix generated from a branch name puts attacker input into your workflow definition.

pull_request runs the contributor’s code with a read-only token and no secrets. That restriction is the platform protecting you, and it is why fork pull requests cannot comment, label, or push.

pull_request_target was added for workflows that legitimately need write access on a fork pull request — labelling, welcoming first-time contributors. It differs in two ways:

  • It runs the workflow file from the base branch, not the pull request.
  • It runs with a read-write token and full access to secrets.

Used as intended — reading metadata, applying a label, never checking out the pull request — it is fine.

The rule: pull_request_target must never check out or execute the pull request’s code. If it needs the code, it is the wrong trigger.

When a workflow genuinely must both run untrusted code and write results back, split it in two.

Workflow one — triggered by pull_request, read-only, no secrets. It runs the contributor’s code and writes its findings to an artifact:

on: pull_request
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: npm ci && npm test -- --reporter json > report.json
- name: Record the PR number
run: echo "${{ github.event.pull_request.number }}" > pr-number.txt
- uses: actions/upload-artifact@v7
with:
name: report
path: |
report.json
pr-number.txt

Workflow two — triggered by workflow_run. It runs the version of itself that is on the default branch, so a pull request cannot modify it. It downloads the artifact and comments:

on:
workflow_run:
workflows: ["PR tests"]
types: [completed]
permissions:
contents: read
pull-requests: write
jobs:
comment:
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v8
with:
name: report
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Post the result
uses: actions/github-script@v9
env:
REPORT_PATH: report.json
with:
script: |
const fs = require('fs');
const number = Number(fs.readFileSync('pr-number.txt', 'utf8').trim());
if (!Number.isInteger(number) || number <= 0) {
core.setFailed('pr-number.txt did not contain a valid number');
return;
}
const report = JSON.parse(fs.readFileSync(process.env.REPORT_PATH, 'utf8'));
const passed = Number(report.passed) || 0;
const failed = Number(report.failed) || 0;
await github.rest.issues.createComment({
...context.repo,
issue_number: number,
body: `Tests: ${passed} passed, ${failed} failed.`,
});

The privileged workflow never runs the contributor’s code. It only reads data the untrusted job produced.

Do not check out and build a fork’s code on a self-hosted runner. The isolation that makes a hosted runner acceptable for this does not exist on your own hardware.

Set persist-credentials: false on checkout in jobs that do not need to push. By default, actions/checkout leaves the token in the local git config, where any later step — including code from the repository — can read it:

- uses: actions/checkout@v7
with:
persist-credentials: false

Scope permissions per job, so the job running dependencies is not the job holding write access. See least-privilege permissions.

Require approval for fork pull request workflows in Settings → Actions → General, so nothing from outside runs unreviewed.

Terminal window
{/* Every pull_request_target workflow each one needs reading */}
grep -rln "pull_request_target" .github/workflows/
{/* Expressions substituted directly into run blocks */}
grep -rnE '^\s+run:.*\$\{\{' .github/workflows/
{/* Checkouts of a pull request head ref */}
grep -rn "pull_request.head" .github/workflows/

The second command produces false positives — ${{ github.sha }} in a run: is harmless — but it is a short list to read, and the one genuine finding is worth the noise.

  1. Run the three audit commands across your repositories. Read every pull_request_target workflow and confirm none of them check out the head ref.

  2. Take a workflow that interpolates github.event.pull_request.title into a run: block. Convert it to the env: form.

  3. Verify the safe version behaves identically for a normal title — the change should be invisible in ordinary use.

  4. Build the two-workflow workflow_run pattern for something small, such as reporting a line count. Confirm the privileged workflow never checks out the pull request.

  5. Add the PR-number validation and confirm the job fails cleanly when the artifact contains something unexpected.

  6. Add persist-credentials: false to checkout in a job that does not push, and confirm it still works.

Check your understanding

3 questions — each one asks you to predict what Git or GitHub will do, not to recall a flag.

A workflow on `pull_request_target` checks out `github.event.pull_request.head.sha` and runs `npm install`. What is the exposure?
Show answer

Arbitrary code from the fork runs with the base repository's secrets and write token — `pull_request_target` runs with the base repository's privileges precisely so it can comment or label. Checking out and executing the PR's code hands those privileges to the contributor. The rule: never check out or execute the PR's code in this event.

You need to comment on fork PRs with results from running their code. What is the safe structure?
Show answer

A `pull_request` workflow (read-only, no secrets) runs the code and uploads an artifact; a `workflow_run` workflow reads it and comments — Splitting untrusted execution from privileged follow-up is the pattern: the `pull_request` job cannot leak anything, and the `workflow_run` job never executes the fork's code.

`actions/checkout` is used in a job that later runs a third-party action. Why does the lesson recommend `persist-credentials: false`?
Show answer

Otherwise the token stays in `.git/config`, readable by any later step — By default checkout writes the token into the local git config so later git commands work. Any subsequent step — including third-party code — can read it. Disabling persistence removes that exposure when later steps do not need to push.

GitHub Actions Security ChecklistAudit your workflows against the failure modes that actually cause incidents. Free and complete.

Get the production security checklists and Actions hardening templates from the Professional Toolkit.