Safeguard
Security Guides•

Stopping script injection in GitHub Actions workflows

How script injection works in GitHub Actions run steps and the fix GitHub recommends: pass untrusted values through an environment variable, not inline.

Safeguard Security Team
3 min read

Script injection happens when a workflow pastes an untrusted value, such as a pull request title, straight into a shell script. GitHub's recommended fix is to assign the value to an environment variable and read the variable in the script, so the value stays data and never becomes part of the code.

How the injection works

Expressions in a workflow are expanded before the shell runs. If a run step contains an expression that reads a pull request title, the runner builds the script text first and then executes it. Whatever the title contains is now part of the script.

A title containing a quote, a semicolon and a command can end the intended line and start a new one. The attacker only needs to open a pull request or an issue. They do not need write access.

Here is the risky shape:

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

The fix GitHub recommends

GitHub's hardening guide calls the intermediate environment variable the preferred approach for inline scripts. You set the expression under env, then reference the shell variable in the script.

- name: Print title
  env:
    TITLE: ${{ github.event.pull_request.title }}
  run: echo "$TITLE"

Now the value is stored as data in the environment. The guide shows an injection attempt in the title being printed as a plain string instead of executed.

Two small habits go with it. Double quote the shell variable so word splitting does not surprise you. And where you can, pass the value as an argument to a JavaScript action, which avoids the shell entirely.

Which values count as untrusted

Anything a person outside your trust boundary can type. Pull request titles and bodies, issue titles, branch names, commit messages and author names are the usual ones. The GitHub page gives the pull request title as its worked example and does not list every context, so treat any text field from an event as untrusted until you decide otherwise.

Values the platform generates, such as a numeric run ID, are low risk. Free text is the problem.

Find existing cases

Search your workflows for expressions that read from github.event inside run blocks. Each hit is a candidate. Move the expression to env and reference the variable.

GitHub also notes that CodeQL can scan for potentially vulnerable workflows, and recommends enabling default setup for code scanning with GitHub Actions scanning turned on. That gives you a repeatable check after the first cleanup.

Make it stick

A one time cleanup decays. Add a code owner for the workflow directory so changes get a second pair of eyes, and consider a required check that scans workflow files on every pull request. See how CODEOWNERS can protect workflow files.

Safeguard policies and gates can hold a pull request when a scan finds a problem, which pairs with the review step above. You can start with the free scan at gold.safeguard.sh/scan.

Quick check

Is quoting the expression enough to stop injection?

No. The expression is expanded before the shell parses the script, so a value can close your quote. Use an environment variable instead.

Does this apply to composite actions too?

Any step that builds a shell script from event data has the same risk. Pass the value through an input or environment variable and read it as a shell variable.

Sources

Last reviewed: 19 October 2026.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.