GitHub Actions Supply Chain Hardening: Pinning, Permissions, and OIDC Token Security
The Threat Surface
GitHub Actions is the dominant CI/CD platform for open source and a large fraction of enterprise software. Its convenience — a marketplace of thousands of reusable actions, tight GitHub API integration, and zero infrastructure to manage — is also its attack surface. Every uses: directive is a dependency. Every secret mapped into a job is a target. Every broad GITHUB_TOKEN permission is a lateral movement vector.
Two incidents illustrate the concrete risk. In March 2023, the tj-actions/changed-files action was compromised after an attacker obtained the maintainer’s personal access token. The attacker force-pushed malicious code to every version tag of the action — over 300 tags — and inserted a payload that printed CI secrets (including GITHUB_TOKEN, AWS keys, npm tokens, and Docker Hub credentials) to the workflow log in an encoded form that bypassed GitHub’s secret masking. Any public repository using the action by tag immediately exposed its secrets in public workflow logs. The attack was detected through an alert on a downstream repository, not by GitHub’s infrastructure. The same campaign extended to reviewdog/action-setup, reviewdog/action-golangci-lint, and several other reviewdog ecosystem actions — all compromised via the same stolen credential, all pushing the same exfiltration payload.
The mechanism is identical in each case: maintainer credential compromise → force-push to mutable tag → next workflow run executes attacker code. No workflow file changes. No PR. No diff to review. The attack is silent from the consumer’s perspective until secrets appear somewhere they shouldn’t.
Pinning to Commit SHAs
Tags in Git are mutable references. A tag named v4 points to a commit object, and that pointer can be moved with a force-push. When your workflow declares uses: actions/checkout@v4, GitHub resolves v4 to whatever commit that tag currently points to — at execution time, every time. If the tag moves between two workflow runs, the two runs execute different code.
Commit SHAs are immutable. A SHA is the SHA-1 (and for newer Git objects, SHA-256) hash of the content of the commit object itself. No one can change what a SHA points to without producing a different SHA. Pinning to a full 40-character commit SHA makes the reference immune to force-push attacks.
- uses: actions/checkout@v4
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
The first line is vulnerable. The second line is not. The comment preserves human readability without sacrificing the security property.
To resolve the current SHA for a tag without cloning:
gh api repos/actions/checkout/git/refs/tags/v4.1.1 \
--jq '.object.sha'
Annotated tags have their own SHA distinct from the commit they point to. For annotated tags, peel through to the commit:
gh api repos/actions/checkout/git/refs/tags/v4.1.1 \
--jq '.object.url' \
| xargs gh api --jq '.object.sha // .sha'
Audit all workflow files in a repository for non-SHA references:
grep -rn 'uses:' .github/workflows/ | grep -v '@[0-9a-f]\{40\}'
A hardened repository produces no output. Any line in the output is a mutable reference that needs remediation.
Automating SHA Updates with Dependabot and Renovate
SHA pinning creates a maintenance obligation: when a new action version releases, someone must update the pin. Without automation, teams pin once and then fall behind on security patches because updating SHA pins manually is tedious. Dependabot handles this.
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
commit-message:
prefix: "chore(actions)"
groups:
actions-minor:
update-types:
- "minor"
- "patch"
Dependabot opens PRs each week with updated SHAs and version comments for any actions that released new versions. The PR diff shows exactly which SHA changed and to what. Review the PR, verify the new SHA corresponds to the published release on the action’s repository, and merge. This is a 30-second check per action.
Renovate provides finer control, particularly useful for organisations managing many repositories or wanting automerge policies differentiated by action risk level:
{
"extends": ["config:base"],
"github-actions": {
"enabled": true,
"pinDigests": true
},
"packageRules": [
{
"matchManagers": ["github-actions"],
"matchUpdateTypes": ["patch"],
"automerge": true,
"automergeType": "pr",
"platformAutomerge": true
},
{
"matchManagers": ["github-actions"],
"matchUpdateTypes": ["major", "minor"],
"automerge": false,
"reviewers": ["team:platform-security"]
}
]
}
pinDigests: true causes Renovate to convert existing tag references to SHA pins on first run. Patch-level action updates automerge after CI passes. Major and minor updates require a team member to review — appropriate because major version bumps can change the action’s behaviour or introduce new permissions requirements.
One operational failure mode: Dependabot PRs accumulate in the queue because no one is assigned to merge them. After a few months, the team starts bypassing SHA pinning because “Dependabot isn’t keeping up.” Prevent this by assigning ownership: a designated person or rotation reviews and merges Dependabot action PRs weekly, with a policy that any PR open for more than seven days with passing CI is auto-merged unless someone objects.
GITHUB_TOKEN Minimum Permissions
The GITHUB_TOKEN is an automatically generated credential scoped to the repository and job. Its default permissions vary by repository setting, and in many organisations the default is read-write for all repository resources — contents, pull-requests, packages, deployments, security-events, and more. A compromised action running with this broad token can push commits, create releases, modify workflow files, approve pull requests, and interact with the GitHub API as the repository.
The correct model: declare permissions: {} at the workflow level to strip all defaults, then grant each job only the permissions it actually uses.
name: CI
on:
push:
branches: [main]
pull_request:
permissions: {}
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- run: make test
security-scan:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: github/codeql-action/analyze@c0d1daa7f7e14667747d73a7dbb never mind, pinned SHA below
- uses: github/codeql-action/analyze@8a470fddafa5cbb6266ee11b37ef4d8aae19c571 # v3.24.6
with:
category: "/language:javascript"
release:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
permissions:
contents: write
packages: write
id-token: write
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- run: make release
The test job can check out code and run tests. It cannot push commits. The security-scan job can upload SARIF results to the Security tab. It cannot write to repository contents. The release job has elevated permissions but only runs on pushes to main. A compromised action in the test job has a token scoped to contents: read — it can exfiltrate the token, but the token has no write surface to exploit.
Change the repository default in Settings > Actions > General > Workflow permissions to “Read repository contents and packages permissions.” This ensures that any workflow file that omits a permissions block gets the read-only default rather than write-all.
OIDC Token Scope Reduction
OIDC federation lets GitHub Actions exchange a short-lived JWT for cloud credentials without storing long-lived secrets. The workflow requests an id-token, sends it to the cloud provider’s token endpoint, and receives credentials scoped according to the provider’s trust policy. No AWS access keys or GCP service account JSON files in repository secrets.
The attack surface is the trust policy on the cloud side. A broad trust policy — one that trusts any workflow in any repository in your organisation — means a compromised action in any workflow can request cloud credentials. Narrow the trust policy to the specific repository, branch, and environment that legitimately needs cloud access.
AWS example using IAM role trust policy:
{
"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",
"token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:environment:production"
}
}
}
]
}
The sub claim format repo:org/repo:environment:name restricts the role to workflows running in the production environment of the specific repository. A workflow in a different repository, or in the same repository without the environment: declaration, receives a sub claim that does not match the condition and cannot assume the role.
For branch-specific access (staging deployments from main only):
"token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:ref:refs/heads/main"
For tag-based access (releases from version tags only):
"token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:ref:refs/tags/v*"
The corresponding workflow:
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
with:
role-to-assume: arn:aws:iam::123456789012:role/prod-deploy
aws-region: us-east-1
- run: aws s3 sync ./dist s3://my-production-bucket/
GCP trust policy equivalent using Workload Identity Federation:
- uses: google-github-actions/auth@55bd3a7c6e2ae7cf1877fd1ccb9d54c0503c457c # v2.1.2
with:
workload_identity_provider: projects/123/locations/global/workloadIdentityPools/github/providers/github
service_account: prod-deploy@my-project.iam.gserviceaccount.com
The Workload Identity Pool attribute condition on the GCP side should include attribute.repository and attribute.ref claims to restrict which workflows can impersonate the service account.
Environment Protection Rules
GitHub Environments add an approval gate between the workflow trigger and the deployment step. A workflow job that declares environment: production pauses at that job until the required reviewers approve, any configured wait timer expires, and the branch matches the allowed deployment branches pattern.
Configure environments in Settings > Environments:
- Required reviewers: two people from the security or release team
- Wait timer: five minutes (gives reviewers time to notice and cancel automated deployments they didn’t expect)
- Deployment branches: selected branches, pattern
main - Environment secrets: production credentials scoped to this environment only
A compromised action running in a job without the environment: declaration cannot access environment-scoped secrets. An action that runs in the environment-gated job cannot skip the approval requirement — the approval is enforced by GitHub’s infrastructure, not by workflow logic that could be bypassed by code injection.
jobs:
deploy-staging:
runs-on: ubuntu-latest
environment: staging
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- run: ./deploy.sh staging
deploy-production:
runs-on: ubuntu-latest
needs: deploy-staging
environment: production
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- run: ./deploy.sh production
The deploy-production job requires both deploy-staging to succeed and the two designated reviewers to approve before any production-scoped credentials are requested. This is a hard gate for SLSA Build Provenance requirements and audit trail purposes.
StepSecurity Harden-Runner
SHA pinning and permission restriction prevent the most common attack vectors, but they don’t stop a compromised action that operates within its declared permissions from exfiltrating secrets over the network. Harden-Runner from StepSecurity adds runtime monitoring to the GitHub Actions runner, intercepting system calls to detect unexpected outbound network connections and file access patterns.
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: step-security/harden-runner@91182cccc01eb5e233317f50d14e38ce555d5e7f # v2.10.1
with:
egress-policy: audit
allowed-endpoints: >
github.com:443
api.github.com:443
objects.githubusercontent.com:443
registry.npmjs.org:443
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- run: npm ci
- run: npm test
egress-policy: audit logs all outbound connections without blocking them — use this to establish a baseline of expected endpoints. Once you know what your workflow legitimately contacts, switch to egress-policy: block with an explicit allowed-endpoints list:
- uses: step-security/harden-runner@91182cccc01eb5e233317f50d14e38ce555d5e7f # v2.10.1
with:
egress-policy: block
allowed-endpoints: >
github.com:443
api.github.com:443
objects.githubusercontent.com:443
registry.npmjs.org:443
nodejs.org:443
In block mode, any outbound connection to an endpoint not on the allowlist causes the step to fail immediately. A compromised action that attempts to POST credentials to an attacker-controlled server receives a connection refused. The exfiltration channel used in the tj-actions/changed-files incident — an HTTPS POST to a domain the attacker registered — would have been blocked by this control.
Harden-Runner also detects when workflow steps write to unexpected file paths and when processes run that were not present in a baseline scan. These signals surface in the StepSecurity dashboard, and can be exported to a SIEM via the API.
Secret Detection in Action Logs
GitHub automatically masks the values of secrets registered in the repository’s secret store when they appear in workflow logs — any exact string match is replaced with ***. This masking fails in predictable ways: base64-encoded secrets, secrets split across multiple log lines, and secrets derived from registered values (JWT claims computed from a secret key) are not masked.
The tj-actions/changed-files payload exploited exactly this: it base64-encoded each captured secret before printing it to the log, producing output that GitHub’s masking regex did not match. Anyone with read access to the repository — which for public repositories means anyone — could retrieve the workflow run log and decode the secrets.
Supplement GitHub’s built-in masking with explicit detection steps:
- name: Check for unmasked secrets in log output
if: always()
run: |
LOG_URL="https://api.github.com/repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/logs"
curl -sL -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
-H "Accept: application/vnd.github+json" \
"${LOG_URL}" -o /tmp/workflow-logs.zip
unzip -p /tmp/workflow-logs.zip > /tmp/all-logs.txt
if grep -qP '[A-Za-z0-9+/]{40,}={0,2}' /tmp/all-logs.txt; then
echo "::warning::Base64-encoded content found in logs — review for secret exfiltration"
fi
For comprehensive secret scanning in the repository itself, push protection via GitHub Advanced Security blocks commits containing known secret patterns at push time. Custom patterns extend coverage to organisation-specific token formats:
- name: Scan repository for secrets
uses: trufflesecurity/trufflehog@main
with:
path: ./
base: ${{ github.event.repository.default_branch }}
head: HEAD
extra_args: --only-verified
The --only-verified flag reduces false positives by attempting to validate detected credentials against their respective APIs before reporting. Unverified findings are still captured in the output for manual review.
Enable secret scanning push protection at the organisation level in Settings > Code security and analysis > Secret scanning > Push protection. This blocks pushes containing known secret formats before they reach the repository history — prevention rather than detection.
Reusable Workflows as a Supply Chain Control
Every third-party action you add to a workflow is a dependency you don’t control. One architectural response is to replace third-party actions with internal reusable workflows that perform the same function using tools you control directly. Instead of using a third-party action to upload SARIF results, run the gh CLI directly. Instead of using an action to configure cloud credentials, call the cloud provider’s CLI directly with OIDC token exchange.
Where third-party actions are genuinely necessary, encapsulate them in internal reusable workflows and reference those instead:
name: Reusable Security Scan
on:
workflow_call:
inputs:
image-ref:
required: true
type: string
secrets:
github-token:
required: true
jobs:
scan:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: step-security/harden-runner@91182cccc01eb5e233317f50d14e38ce555d5e7f # v2.10.1
with:
egress-policy: block
allowed-endpoints: >
github.com:443
api.github.com:443
ghcr.io:443
- uses: aquasecurity/trivy-action@18f2510ee396bbf400402947b394f2dd8c87dbb0 # v0.20.0
with:
image-ref: ${{ inputs.image-ref }}
format: sarif
output: trivy-results.sarif
- uses: github/codeql-action/upload-sarif@8a470fddafa5cbb6266ee11b37ef4d8aae19c571 # v3.24.6
with:
sarif_file: trivy-results.sarif
Consumer workflows call the reusable workflow by reference:
jobs:
security-scan:
uses: your-org/.github/.github/workflows/security-scan.yml@main
with:
image-ref: ghcr.io/your-org/your-image:${{ github.sha }}
secrets:
github-token: ${{ secrets.GITHUB_TOKEN }}
The third-party action versions, Harden-Runner configuration, and egress allowlist are defined once in the internal reusable workflow. All consuming repositories get the hardened configuration automatically. Updating the third-party action SHA in the reusable workflow propagates to every consumer — no repository-by-repository Dependabot PR required.
Protect the reusable workflow file with CODEOWNERS:
.github/workflows/ @your-org/platform-security
Platform security team review is required for any change to the workflow definitions. Consumers cannot modify the internal workflow they’re calling — they can only call it. This is a meaningful supply chain control: the organisation’s security team owns and hardens the action wrappers, and all projects consume those wrappers rather than sourcing their own.
This pattern complements the dependency confusion defences described in Dependency Confusion Defence and the provenance tracking discussed in SLSA Build Provenance. For SBOM generation from the pipeline, see SBOM Generation and Consumption.
Consolidated Hardened Workflow
A workflow that applies all of the above controls:
name: Build, Scan, and Deploy
on:
push:
branches: [main]
pull_request:
permissions: {}
jobs:
build-and-test:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: step-security/harden-runner@91182cccc01eb5e233317f50d14e38ce555d5e7f # v2.10.1
with:
egress-policy: block
allowed-endpoints: >
github.com:443
api.github.com:443
objects.githubusercontent.com:443
registry.npmjs.org:443
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
with:
node-version: "20"
cache: "npm"
- run: npm ci
- run: npm test
security-scan:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: step-security/harden-runner@91182cccc01eb5e233317f50d14e38ce555d5e7f # v2.10.1
with:
egress-policy: block
allowed-endpoints: >
github.com:443
api.github.com:443
ghcr.io:443
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: github/codeql-action/init@8a470fddafa5cbb6266ee11b37ef4d8aae19c571 # v3.24.6
with:
languages: javascript
- uses: github/codeql-action/analyze@8a470fddafa5cbb6266ee11b37ef4d8aae19c571 # v3.24.6
deploy-production:
runs-on: ubuntu-latest
needs: [build-and-test, security-scan]
if: github.ref == 'refs/heads/main'
environment: production
permissions:
contents: read
id-token: write
steps:
- uses: step-security/harden-runner@91182cccc01eb5e233317f50d14e38ce555d5e7f # v2.10.1
with:
egress-policy: block
allowed-endpoints: >
github.com:443
sts.amazonaws.com:443
s3.amazonaws.com:443
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
with:
role-to-assume: arn:aws:iam::123456789012:role/prod-deploy
aws-region: us-east-1
- run: aws s3 sync ./dist s3://my-production-bucket/
Trade-offs
| Control | Operational Cost | Risk Mitigated |
|---|---|---|
| SHA pinning | Weekly Dependabot PR review | Tag hijacking via force-push |
permissions: {} + per-job grants |
Steps fail if permissions miscounted; iterative debugging | Stolen token has no write surface |
| OIDC trust policy narrowing | Trust policy must be updated when repo/branch structure changes | Any workflow in org can assume production role |
| Environment approval gates | Deployment latency; approver availability bottleneck | Automated pipelines bypass without human review |
| Harden-Runner egress block | Baseline audit phase required; allowlist maintenance | Credential exfiltration over HTTPS |
| Reusable internal workflows | Central workflow maintenance burden | Per-team divergence on action versions and configs |
The controls compound. SHA pinning makes tag hijacking ineffective. Minimum permissions mean a stolen token has limited scope. OIDC narrowing means a stolen token that does have cloud access can only assume the specific role tied to the specific environment. Harden-Runner egress blocking prevents the credential from leaving the runner even if all other controls fail. Environment gates add a human review point before the most sensitive credentials are accessed at all.