Securing GitHub Actions
Securing GitHub Actions
A CI/CD pipeline has privileged access to your codebase, your secrets, and your production infrastructure. An insecure pipeline is a direct attack surface. High-profile supply chain incidents — Solar Winds, the Codecov breach, the tj-actions/changed-files compromise in 2023 — all exploited CI/CD systems to steal secrets or push malicious code. Big-tech security teams treat the pipeline as a trust boundary as carefully guarded as a production network perimeter. This lesson covers four interconnected controls: pinning actions to commit SHAs, tightening GITHUB_TOKEN permissions, understanding and preventing pwn request attacks, and governing which actions are allowed in your organization.
Pinning Actions to Commit SHAs
Every uses: owner/action@ref line in your workflow is a third-party dependency. When you write uses: actions/checkout@v4, GitHub resolves the v4 tag to whatever commit it currently points to and runs that code with full runner access — including your secrets. Mutable tags are the problem: a maintainer (or an attacker who has compromised the maintainer) can move the tag to a new commit silently. Your workflow will start running different code on its next run with no indication in your workflow file.
The fix is to pin every action to an immutable full-length commit SHA:
To find the SHA for a tag: open the action repository on GitHub, navigate to the release or tag, and copy the full 40-character commit SHA from the URL or the release commit list. Alternatively, use the gh CLI:
.github/dependabot.yml with package-ecosystem: github-actions) automate this — they open PRs to update your pinned SHAs when new versions of actions are released, so you get the security of pinning without the manual tracking cost.GITHUB_TOKEN Permissions: Principle of Least Privilege
Every workflow run is automatically provisioned with a short-lived GITHUB_TOKEN. This token can read and write to your repository — create issues, push commits, publish packages, write to the GitHub Container Registry, and more. The default permissions depend on your organization settings, but GitHub's own default grants write access to contents for repositories with older settings. That is far too broad for a test job.
The control is the permissions key, which can be set at the workflow level (applies to all jobs) and overridden at the job level (applies to that job only). Always set the narrowest permissions you can:
permissions: write-all: It is tempting to set permissions: write-all to "just make it work" when a step fails with a permission error. This grants the workflow token write access to every API scope GitHub exposes — issues, pull-requests, packages, deployments, checks, code scanning, and more. A compromised action in your dependency chain (even a transitive one via a reusable workflow) can then create fake releases, approve its own PRs, or exfiltrate secrets via GitHub API calls. Always investigate what specific scope a step actually needs and grant only that.The pwn-Request Attack: The Most Underestimated Risk
The pwn-request (short for "owned pull request") is one of the most dangerous vulnerability classes in GitHub Actions, and many teams do not even know it exists. Understanding it is essential.
The attack exploits the difference between two trigger events:
pull_request— triggered when a PR is opened from a fork. By design, this event runs the base branch workflow code, and theGITHUB_TOKENit provides has read-only permissions and no access to secrets. This is safe.pull_request_target— triggered in the context of the base repository, not the fork. The workflow can access secrets and theGITHUB_TOKENhas write permissions. This event was created to allow things like auto-labeling PRs or posting comments from forks, but it is extremely dangerous if you check out the PR code and run it.
The attack pattern: a malicious contributor opens a PR from a fork. If your workflow uses pull_request_target and also checks out the PR code (using github.event.pull_request.head.sha), the attacker's code runs with full write access to your repository and full access to your secrets. In 2023, this class of vulnerability compromised major open-source projects, leaking signing keys and deployment credentials.
pull_request_target: Some legitimate use cases require it — for example, auto-labeling a PR from a fork based on changed paths, or posting a test coverage comment. The safe pattern is a split workflow: run CI with pull_request (no secrets, untrusted code), and use pull_request_target only for a separate job that never checks out PR code. Pass data between them via artifacts and the workflow_run event. This is the pattern used by projects like CPython and TypeScript.Allowed Actions Policy: Organization-Level Governance
At the organization or enterprise level, GitHub allows administrators to restrict which actions can be used across all repositories. This is the policy layer that prevents an engineer from accidentally introducing an unvetted third-party action into a critical pipeline.
The options (configured in GitHub UI under Organization → Settings → Actions → General) are:
- Allow all actions — any action from any public repository can be used. This is the default and is appropriate only for personal repositories or low-risk projects.
- Allow select actions — the most important setting for production organizations. You can specify: allow actions from GitHub itself (
actions/*), allow actions from verified creators (a curated list GitHub maintains), and/or allow specific third-party actions by exactowner/repo@SHAor a glob pattern. This is what Google, Stripe, and similar companies use for internal GitHub organizations. - Disable GitHub Actions — nuclear option; disables the entire Actions platform for the organization.
Beyond the UI policy, you can enforce action pinning and security practices at the workflow level using Zizmor or actionlint in CI — static analysis tools that catch unpinned actions, dangerous patterns like pull_request_target with checkout, and missing permission blocks:
Command Injection via Workflow Expressions
One more critical security pattern that every engineer must know: untrusted data in workflow expressions. When you interpolate a GitHub context variable like ${{ github.event.pull_request.title }} directly into a run: shell command, you are vulnerable to injection. An attacker crafts a PR title containing shell metacharacters or commands, and those commands run in your runner with whatever permissions the job has.
github.head_ref into a shell command. The rule is simple — never interpolate any user-controllable context value (github.event.*, github.head_ref, PR titles, commit messages, issue body) directly into a run: block. Always go through an environment variable.Security in GitHub Actions is not a checklist you run once — it is an ongoing discipline. Pin your actions and automate the updates with Dependabot. Set permissions: contents: read at the workflow level and grant write access only where a specific job needs it. Never use pull_request_target to run untrusted fork code. Enable the organization-level allowed-actions policy. Run actionlint in CI. And always pass user-controlled data through environment variables, never via expression interpolation into shell. Apply these five controls and you will have a pipeline posture that matches what security-conscious engineering organizations require.