After writing about the tj-actions supply chain attack and GitHub’s 2026 security roadmap, I audited the pipeline that deploys this site. Two workflows: deploy.yml (builds and syncs to S3 via CloudFront) and scan.yml (Trivy, Checkov, Semgrep, npm audit). Both followed a pattern that is extremely common and that the tj-actions incident demonstrated is genuinely exploitable.
Here is exactly what was wrong and the changes that fixed it.
What the Workflows Looked Like Before
Both workflows used mutable version tags throughout:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
- uses: aws-actions/configure-aws-credentials@v6
- uses: aquasecurity/trivy-action@v0.35.0
- uses: bridgecrewio/checkov-action@v12
The deploy.yml had permissions declared at the workflow level:
permissions:
id-token: write
contents: read
This means id-token: write applied to every job in the workflow. There was only one job, so the practical blast radius was small. But the pattern is wrong: it grants every future job the ability to request an OIDC token, which is not necessary if only one job exchanges credentials with AWS.
The Two Changes
1. Pin every action to its commit SHA
The fix is mechanical. For each action, get the commit SHA that the current version tag resolves to, and replace the tag reference with the SHA. The tag is kept as a comment so it is human-readable:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
- uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6.0.0
- uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0
- uses: bridgecrewio/checkov-action@0ce65fae06c148e349f955c3c35ad049c11e838c # v12
A SHA is immutable. If any of these maintainers silently moves the v6 tag to a different commit - as happened with tj-actions - the SHA reference still resolves to the audited commit. The attack that compromised 23,000 workflows fails at this step.
Getting the SHA for a given tag via the GitHub CLI:
gh api repos/actions/checkout/git/ref/tags/v6.0.2 --jq '.object.sha'
Note: annotated tags return the tag object SHA, not the commit SHA. If the result does not match a commit when you verify it, dereference it:
gh api repos/actions/checkout/git/tags/<tag-object-sha> --jq '.object.sha'
Keeping these up to date is the maintenance concern. The right answer is Dependabot with the github-actions ecosystem configured:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
groups:
actions:
patterns: ["*"]
Dependabot will open PRs that update the pinned SHA and the version comment together when maintainers release new versions. You get the security property of SHA pinning without manual tracking.
2. Deny-by-default permissions, scoped per job
The workflow-level permissions block becomes a deny-all:
permissions: {}
Each job then declares only what it actually requires:
jobs:
deploy:
permissions:
contents: read
id-token: write # only this job exchanges an OIDC token with AWS STS
trivy:
permissions:
contents: read # checkout only - no token exchange needed
The id-token: write permission enables the job to request a GitHub OIDC JWT. That JWT is what aws-actions/configure-aws-credentials exchanges with AWS STS for short-lived credentials. No stored AWS_SECRET_ACCESS_KEY. No rotation schedule. The credentials expire in 15-60 minutes and are scoped to the IAM role’s trust policy.
Granting id-token: write only on the job that needs it means a compromised action in any other job - Trivy, Checkov, Semgrep, npm audit - cannot request an OIDC token and use it to obtain AWS credentials. The blast radius is contained to the deploy job.
The One Remaining Static Credential
SEMGREP_APP_TOKEN is still a stored GitHub secret. Semgrep does not offer OIDC federation, so there is no short-lived alternative. It is scoped to semgrep scan and has no AWS access, which limits the damage if it is stolen.
This is the correct way to think about static credentials that cannot be eliminated: scope them as narrowly as possible, document why OIDC is not available, and revisit when the provider adds federation support.
The Full diff
deploy.yml before and after, trimmed to the security-relevant parts:
-permissions:
- id-token: write
- contents: read
+permissions: {}
jobs:
deploy:
+ permissions:
+ contents: read
+ id-token: write
+
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- - uses: actions/setup-node@v6
+ - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
- - uses: aws-actions/configure-aws-credentials@v6
+ - uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6.0.0
Two changes. Twenty minutes of work including the SHA lookups. The pipeline now matches what the blog described rather than contradicting it.