Skip to content
DevSecOps 14 min read

GitHub Actions Security Hardening: A Field Guide

Almost every Actions workflow we audit has write-all permissions inherited from the default and third-party actions pinned to a mutable tag. This is the hardening guide we run — the nine controls that turn a GitHub Actions setup from a supply-chain liability into something you can actually defend.

Before you start

Most GitHub Actions security breaks fall into two categories: workflows that run untrusted code with production secrets, and repositories that leak their ability to ship code to attackers. This post covers the specific configuration changes that prevent both. We assume you're running CI/CD workflows in Actions today and want to know what's actually exploitable.

Diagram

1. Permissions block is not optional

Almost every Actions workflow we audit has permissions: write-all inherited from the default or no permissions block at all. Repositories created before February 2023 default to read-write for GITHUB_TOKEN; newer repos default to read-only, but most orgs have legacy repos that still grant write-all. This means any code running in that job can modify branch protection rules, approve PRs, or delete releases.

Add a permissions block at the top level of your workflow and set each permission to the minimum required.

name: CI
on: [push, pull_request]
permissions:
  contents: read
  pull-requests: read
  checks: write
jobs:
  test:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
      - run: npm test

Per-job permissions override the workflow-level block. Use this to grant elevated permissions only to jobs that need them — a deployment job might have contents: write, but your test job should have contents: read.

2. Pin third-party actions by SHA

A tag like actions/checkout@v4 is a moving target. The maintainer can push a new commit, retagging v4 to point to malicious code, and your next workflow run pulls the poisoned version. This is not theoretical — it has happened.

Pin every third-party action to a specific commit SHA:

- uses: actions/checkout@0ad4b8fadaa221de15d46a0165f0c60caee9f45b  # v4.1.4
- uses: actions/setup-node@60edb3dd545a775178fdef7a76f52496191713dd0  # v4.0.2

Use a comment with the version so you know what you pinned to. Automated tools like Dependabot can update these pins regularly. Inline GitHub Actions in .github/workflows/ are your code and don't need pinning.

3. Use OIDC for cloud credentials

Do not create long-lived AWS access keys, GCP service account keys, or Azure credentials for Actions. Use OpenID Connect to request short-lived credentials on each job run. GitHub's OIDC token is signed and includes job context (repository, ref, actor, run ID).

Configure your cloud provider to trust GitHub's OIDC issuer and validate the token before issuing credentials. For AWS:

- uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsRole
    aws-region: us-east-1

The IAM role's trust policy binds the assume-role operation to your GitHub repository and specific branches. If a private key leaks, it cannot be used to obtain AWS credentials without the issuer's signature.

4. Reusable workflows as security boundaries

A reusable workflow is a shared workflow that other workflows call. The caller workflow can be pull_request with untrusted code; the reusable workflow that runs deployment logic should be in a separate file with stricter controls.

Reusable workflows run in the context of the called-from repository. This means you can enforce environments, concurrency, and permissions restrictions in the reusable workflow without relying on the caller to set them correctly.

# .github/workflows/deploy.yml (reusable)
on:
  workflow_call:
    inputs:
      environment:
        required: true
        type: string
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{  inputs.environment  }}
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@...
      - uses: aws-actions/configure-aws-credentials@v4

If you have a test.yml workflow that could be poisoned by a PR, have it call a reusable deploy.yml workflow instead of doing deployment directly. The environment protection rules apply to the reusable workflow's job, not the caller.

5. The pull_request_target footgun

pull_request_target runs the workflow from the base branch, not the PR branch. This is useful for workflows that need to write to the repository (labeling, commenting), but it's a credential leak vector if you're not careful.

The trap: the PR author's code is checked out, but the base branch's secrets are available. A malicious PR can exfiltrate your deployment keys.

# BAD: don't do this
on: pull_request_target
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{  github.head_ref  }}
      - run: npm test
      # Now the PR's malicious npm package can read ${{  secrets.DEPLOY_KEY  }}  

If you must use pull_request_target, check out the base branch (default behavior), run only read operations (no npm install from the PR's code), and pass no secrets to the job. For most workflows, use pull_request instead.

6. Environment protection rules and required reviewers

An environment is a named target (production, staging, canary) that can have its own secrets and deployment branch rules. Create environments in your repository settings and configure deployment_branches to restrict which branches can deploy.

For production, require manual review before each deployment:

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://example.com
    steps:
      - uses: actions/checkout@v4
      - run: ./deploy.sh

GitHub will pause the job and require approval from a designated reviewer before the deploy job can proceed. You can restrict which accounts are allowed to approve.

7. Secret scanning and Dependabot configuration

Enable secret scanning in repository settings. GitHub scans your commits for API keys, tokens, and SSH keys that match known patterns. The scanner is free for public repositories and a paid feature for private ones.

Configure Dependabot to scan your Action dependencies and open PRs when updates are available. In .github/dependabot.yml:

version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"

Dependabot pulls are opened against your default branch and do not automatically trigger deployments. This gives you a chance to review the change before it ships.

8. Runner isolation — threat models

GitHub-hosted runners are ephemeral Linux, macOS, or Windows virtual machines. Each job gets a fresh VM. Self-hosted runners are long-lived machines in your network or owned by you.

The risk split: on GitHub-hosted runners, an attacker can only steal secrets from the current job and leak build artifacts. On self-hosted runners, an attacker can persist between jobs, compromise the host OS, and access the network behind the runner.

If you must use self-hosted runners, do not run untrusted code on them. This means no public forks of your repository should be able to trigger workflows on self-hosted runners. In workflow files, use:

jobs:
  deploy:
    runs-on: [self-hosted, production]
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'

This ensures that pull_request events (which include forks) cannot trigger the deployment job on self-hosted infrastructure.

9. Branch protection that prevents pipeline poisoning

Enforce branch protection rules on your main production branches. Require pull requests for all changes, require status checks to pass, and require a code review.

The critical rule: Require a code review from someone other than the author before merging. This prevents a compromised GitHub account from pushing code directly to main.

Use Require approval of the latest reviewable commit to invalidate approvals if the code changes after review. Use Require branches to be up to date before merging to prevent race-condition merges.

For Actions specifically: require that the workflow file itself has been reviewed and approved. This prevents a PR author from modifying the workflow to exfiltrate secrets and running it in a single commit.

The short version

Set an explicit permissions block on every workflow and grant only the minimum required permissions. Pin third-party Actions to commit SHAs, not tags. Use OIDC to request short-lived cloud credentials instead of storing long-lived keys. Deploy via reusable workflows with environment protection rules that require manual review. Avoid pull_request_target unless you understand the secrets leak. Configure Dependabot for regular Action and dependency updates. Never run untrusted code on self-hosted runners. Enforce branch protection with code review and status check requirements on your main branches.

Want us to harden your Actions setup?

We audit your workflows, lock down permissions, wire OIDC, and hand you a configuration your team can maintain. Senior DevSecOps engineers, no SDRs.