GitHub Actions Security Hardening: The Checklist for Supply Chain Attack Prevention
In March 2025, the tj-actions/changed-files GitHub Action was compromised. Attackers modified the action to print CI/CD secrets to workflow logs. Over 23,000 repositories that used the action with a mutable tag (like @v45) were affected. Repositories that pinned to a specific commit SHA were not.
This was not an isolated event. GitHub Actions supply chain attacks quadrupled between 2024 and 2025. The attack surface is real and growing: Actions are third-party code running inside your CI/CD pipeline with access to your cloud credentials, deployment keys, and production systems.
This checklist covers every control that prevents Actions-based supply chain attacks. Each control includes the exact YAML to add to your workflows.
Control 1: Pin All Third-Party Actions to Commit SHA
This is the single most important control. When you reference an Action with a tag (uses: actions/checkout@v4), that tag is a mutable pointer. The Action author can push new code to that tag at any time. When you reference a commit SHA (uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29), you are pinned to an immutable state.
Do not use:
- uses: actions/checkout@v4
- uses: tj-actions/changed-files@v45
- uses: actions/setup-python@main
Use instead (full commit SHA):
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.1
- uses: actions/setup-python@0a5c61591373683505ea898e09424558ab9bea9 # v5.0.0
The comment after the SHA is essential. SHAs are not human-readable; the comment tells reviewers and future maintainers what version the SHA corresponds to.
Finding the commit SHA for an Action:
# Method 1: GitHub UI
# Go to the Action's GitHub page > Tags > click the tag > copy the commit SHA from the URL
# Method 2: GitHub API
curl -s "https://api.github.com/repos/actions/checkout/git/refs/tags/v4" | \
python3 -c "import json,sys; r=json.load(sys.stdin); print(r['object']['sha'])"
# Method 3: StepSecurity Harden-Runner generates pinned SHAs automatically
# Visit app.stepsecurity.io and paste your workflow
Automating SHA updates (so you do not miss security updates):
Use Dependabot to automatically open PRs when a pinned Action has a new release:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
# Dependabot will update the SHA and the comment automatically
With this configuration, you get the security of SHA pinning plus automated PRs when new versions are released.
Control 2: Restrict Workflow Permissions to Least Privilege
By default, GitHub Actions workflows receive a GITHUB_TOKEN with read access to the repository and write access to packages, deployments, and other GitHub resources. Most workflows do not need write access to most of these scopes.
Set the default to read-only at the org or repo level:
In your repository: Settings > Actions > General > Workflow permissions > select 'Read repository contents and packages permissions.'
Or enforce it via workflow YAML:
# At the top of your workflow file:
permissions:
contents: read # minimum for checkout
Grant additional permissions only to the jobs that need them:
jobs:
build:
permissions:
contents: read # checkout only
packages: write # only if pushing to GHCR
deploy:
permissions:
contents: read
id-token: write # OIDC token for cloud auth (see Control 3)
security-scan:
permissions:
contents: read
security-events: write # only if uploading SARIF to Code Scanning
Common permissions by use case:
| Use case | Permissions needed |
|---|---|
| Checkout code only | contents: read |
| Push to GHCR | contents: read, packages: write |
| Create release | contents: write |
| Upload SARIF results | contents: read, security-events: write |
| OIDC for AWS/GCP/Azure | contents: read, id-token: write |
| Comment on PR | contents: read, pull-requests: write |
Why this matters: If an Action is compromised and it runs in a workflow with broad token permissions, the attacker can use the GITHUB_TOKEN to read secrets, push code, modify releases, or trigger other workflows. Least-privilege permissions limit the blast radius.
Briefings like this, every morning before 9am.
Threat intel, active CVEs, and campaign alerts, distilled for practitioners. 50,000+ subscribers. No noise.
Control 3: Use OIDC Instead of Long-Lived Cloud Credentials
The most common way CI/CD pipelines interact with cloud providers (AWS, GCP, Azure) is via long-lived credentials stored as repository secrets: an AWS access key in secrets.AWS_ACCESS_KEY_ID. If those secrets are exfiltrated (via a compromised Action or workflow misconfiguration), an attacker has persistent cloud access until the keys are manually rotated.
OIDC (OpenID Connect) eliminates long-lived credentials. The workflow exchanges a short-lived OIDC token (valid for the duration of the workflow run) for temporary cloud credentials. If an attacker exfiltrates the token, it expires within minutes.
AWS OIDC setup:
jobs:
deploy:
permissions:
id-token: write # required for OIDC
contents: read
steps:
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.1
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
aws-region: us-east-1
# No access key or secret key needed
On the AWS side, create an IAM OIDC Identity Provider for GitHub and a role that trusts it with the appropriate condition:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012: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:*"
}
}
}
]
}
Scope the sub condition to the specific repository and optionally the specific branch (repo:org/repo:ref:refs/heads/main) to prevent other repos from assuming the role.
GCP OIDC uses Workload Identity Federation; Azure uses federated credentials on a service principal. The principle is identical across all three providers.
Control 4: Prevent Malicious PRs From Accessing Secrets
GitHub has two workflow trigger types that differ critically in their access to secrets:
pull_request: Triggered on PRs from forks. Has no access to secrets and runs with read-only token. Safe to run automated tests.pull_request_target: Triggered on PRs but runs in the context of the base repository. Has access to secrets. Used for labeling, commenting, and other privileged operations.
The dangerous pattern:
# DANGEROUS: pull_request_target with checkout of fork code
on:
pull_request_target:
jobs:
build:
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # Checking out fork code
- run: npm test # Running fork code with access to secrets
This pattern lets an attacker submit a malicious PR that runs arbitrary code in your secrets context.
Safe pattern for privileged operations on PRs:
# Safe: Separate the privileged and unprivileged steps into two workflows
# Workflow 1: Runs on pull_request (no secrets, safe to run fork code)
on: pull_request
jobs:
tests:
permissions:
contents: read
steps:
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29
- run: npm test
- uses: actions/upload-artifact@v4
with:
name: test-results
path: ./test-results
# Workflow 2: Runs on workflow_run (can access secrets, does NOT run fork code)
on:
workflow_run:
workflows: ["Run Tests"]
types: [completed]
jobs:
post-results:
permissions:
pull-requests: write
checks: write
steps:
- uses: actions/download-artifact@v4
with:
name: test-results
- name: Post comment to PR
# This runs in your repo context, never executes fork code
Never use pull_request_target with a checkout of the PR's head commit. If you must use pull_request_target, only check out code from the base branch, not from the PR.
Control 5: Audit and Restrict Secrets, Dependencies, and Self-Hosted Runners
Secrets scoping:
Secrets defined at the organization level are available to all repositories. Secrets defined at the repository level are available to all workflows in that repository. Apply least-privilege:
- Cloud credentials (AWS, GCP, Azure): per-repository secrets, scoped to the specific environment (production, staging) via GitHub Environments
- Deployment keys: per-environment secrets using GitHub Environments with required reviewers for production
- API keys for external services: per-repository, scoped to only the workflows that need them
# Use GitHub Environments to gate production secrets
jobs:
deploy-production:
environment: production # Requires approval from configured reviewers
steps:
- name: Deploy
env:
PROD_KEY: ${{ secrets.PROD_DEPLOY_KEY }} # Only available after approval
Prevent secrets from appearing in logs:
# Never echo secrets directly
- run: echo "${{ secrets.MY_SECRET }}" # WRONG: may appear in logs
# Pass via environment variables
- run: ./deploy.sh
env:
API_KEY: ${{ secrets.API_KEY }} # Masked in logs; passed as env var to script
Audit your current Actions usage:
# Find all external Actions used across your org (requires GitHub CLI)
gh repo list YOUR-ORG --limit 1000 --json nameWithOwner --jq '.[].nameWithOwner' | \
while read repo; do
gh api /repos/$repo/contents/.github/workflows --jq '.[].name' 2>/dev/null | \
while read workflow; do
gh api /repos/$repo/contents/.github/workflows/$workflow --jq '.content' | \
base64 --decode | grep -oE 'uses: [^@]+@[^\n]+' | grep -v 'actions/' | echo "$repo: $(cat)"
done
done
This lists all non-GitHub-owned Actions in use across your organization, helping you identify which ones need SHA pinning and which are high-risk third-party dependencies.
Self-hosted runner security:
Self-hosted runners execute workflow jobs on your infrastructure. Key risks:
- A compromised workflow job can persist artifacts on the runner between jobs (unlike ephemeral GitHub-hosted runners)
- A fork PR can request a self-hosted runner job if the runner is not scoped to specific repositories
Mitigation:
# Restrict self-hosted runners to specific trusted workflows only
jobs:
deploy:
runs-on: self-hosted # Only use for trusted workflows, not PR checks
And in your runner registration: register runners at the repository level (not organization level) to prevent them from being used by other repositories.
Using StepSecurity to Automate These Controls
StepSecurity (stepsecurity.io) is a free tool that automates several of these hardening controls:
Harden-Runner Action:
- uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
with:
egress-policy: audit # Start in audit mode; switch to 'block' once tested
allowed-endpoints: >
github.com:443
api.github.com:443
registry.npmjs.org:443
Harden-Runner monitors all network egress from workflow jobs. In audit mode, it logs all outbound connections. In block mode, it blocks any connection not on the allowlist -- including connections a compromised Action might make to exfiltrate secrets.
The tj-actions attack revisited: The compromised action printed secrets to logs and also attempted to POST them to an external endpoint. Harden-Runner with egress-policy: block and an allowlist that did not include the attacker's endpoint would have blocked the exfiltration even if the action ran.
Free automated PR generator:
StepSecurity also offers a free service at app.stepsecurity.io that analyzes your workflow files and opens a PR that:
- Pins all Actions to their current commit SHA
- Adds minimum permissions declarations
- Adds Harden-Runner
For organizations with many repositories, this is the fastest path to baseline hardening.
The bottom line
GitHub Actions supply chain attacks are not theoretical. They compromised 23,000 repositories in 2025. Every control in this checklist addresses a real attack vector with a concrete YAML implementation. The priority order: (1) pin all third-party Actions to commit SHA today -- it prevents the entire class of tag-mutation attacks; (2) set workflow permissions to read-only by default; (3) migrate cloud credentials to OIDC; (4) audit for pull_request_target misuse. The SHA pinning alone would have made the tj-actions attack a non-event for your pipelines.
Frequently asked questions
What was the tj-actions supply chain attack?
In March 2025, attackers compromised the GitHub account that maintained tj-actions/changed-files, a widely-used Action that detects which files changed in a PR. They pushed malicious code to the existing version tags that printed CI/CD secrets and environment variables to workflow logs. Any repository using the action via a mutable tag (like @v45) automatically ran the malicious code on their next workflow trigger. Repositories that had pinned the action to a specific commit SHA were not affected because their workflows continued running the previously-verified code.
Is SHA pinning really sufficient protection?
SHA pinning prevents tag-mutation attacks where an attacker modifies an existing version. It does not prevent a scenario where the attacker also controls a new release that a Dependabot PR updates to -- which is why human review of Dependabot PRs for Actions updates matters. SHA pinning combined with required PR review for Dependabot automation updates provides strong protection. The combination of pinning plus Harden-Runner egress blocking provides defense-in-depth: even if a compromised action runs, it cannot exfiltrate secrets to an external endpoint.
What is the difference between pull_request and pull_request_target triggers?
pull_request runs in the context of the fork -- it has no access to repository secrets and uses a read-only token. This is safe for running tests on PR code from external contributors. pull_request_target runs in the context of the base repository, has access to secrets, and uses the full GITHUB_TOKEN. It was designed for scenarios like commenting on PRs or posting test results, where write access to the base repository is needed. The danger arises when pull_request_target workflows check out and execute code from the PR's head branch -- that combines external (fork) code execution with privileged base repository access.
Does OIDC work for all cloud providers?
Yes. AWS, GCP (via Workload Identity Federation), and Azure (via federated credentials on a service principal) all support OIDC-based authentication from GitHub Actions. The setup steps differ slightly per provider but the principle is the same: GitHub issues a short-lived OIDC token, the cloud provider verifies the token's claims (repository, branch, environment) against a trust policy, and issues temporary credentials. No long-lived cloud credentials are stored in GitHub Secrets.
How do I find which Actions in my organization are unpinned?
StepSecurity's free tool at app.stepsecurity.io can scan your workflows and identify unpinned Actions. For a self-hosted approach, use the GitHub CLI command in Control 5 to list all external Actions across your org. For automated remediation, StepSecurity can open PRs to pin all Actions in bulk. If your organization has more than 20 repositories with workflows, the automated PR approach is significantly faster than manual pinning.
Should I use GitHub-hosted or self-hosted runners?
GitHub-hosted runners are ephemeral -- they spin up fresh for each job and are destroyed after. This eliminates persistence risks from previous jobs. Self-hosted runners are appropriate when you need specific hardware, access to private network resources, or significant compute that exceeds GitHub-hosted runner limits. If you use self-hosted runners, do not expose them to fork PR workflows -- only use them for trusted workflows on your own branches. Register runners at the repository level, not the organization level, to prevent cross-repository access.
Sources & references
Free resources
Critical CVE Reference Card 2025–2026
25 actively exploited vulnerabilities with CVSS scores, exploit status, and patch availability. Print it, pin it, share it with your SOC team.
Ransomware Incident Response Playbook
Step-by-step 24-hour IR checklist covering detection, containment, eradication, and recovery. Built for SOC teams, IR leads, and CISOs.
Get threat intel before your inbox does.
50,000+ security professionals read Decryption Digest for early warnings on zero-days, ransomware, and nation-state campaigns. Free, weekly, no spam.
Unsubscribe anytime. We never sell your data.

Founder & Cybersecurity Evangelist, Decryption Digest
Cybersecurity professional with expertise in threat intelligence, vulnerability research, and enterprise security. Covers zero-days, ransomware, and nation-state operations for 50,000+ security professionals weekly.
The Mythos Brief is free.
AI that finds 27-year-old zero-days. What it means for your security program.
