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 action —
with: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 fromprocess.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.
The second mistake: pull_request_target
Section titled “The second mistake: pull_request_target”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.
The safe pattern: workflow_run
Section titled “The safe pattern: workflow_run”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.txtWorkflow 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.
Other things worth locking down
Section titled “Other things worth locking down”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: falseScope 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.
Auditing
Section titled “Auditing”{/* 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.
Exercise
Section titled “Exercise”-
Run the three audit commands across your repositories. Read every
pull_request_targetworkflow and confirm none of them check out the head ref. -
Take a workflow that interpolates
github.event.pull_request.titleinto arun:block. Convert it to theenv:form. -
Verify the safe version behaves identically for a normal title — the change should be invisible in ordinary use.
-
Build the two-workflow
workflow_runpattern for something small, such as reporting a line count. Confirm the privileged workflow never checks out the pull request. -
Add the PR-number validation and confirm the job fails cleanly when the artifact contains something unexpected.
-
Add
persist-credentials: falseto checkout in a job that does not push, and confirm it still works.
Then what?
Section titled “Then what?”Check your understanding
3 questions — each one asks you to predict what Git or GitHub will do, not to recall a flag.