A GitHub Actions workflow is one of the most privileged programs in your organization that almost nobody reviews like one. It runs with an automatically minted GITHUB_TOKEN, it can reach cloud credentials, it clones your source, and it executes arbitrary third-party code that you referenced by a tag you never verified. The March 2025 compromise of tj-actions/changed-files (CVE-2025-30066) made the cost concrete: a malicious commit was pushed to the action, its existing version tags were quietly re-pointed at that commit, and roughly 23,000 repositories that referenced the action by tag began dumping CI secrets into their public build logs. The initial foothold was traced to a leaked token from a second compromised action, reviewdog/action-setup (CVE-2025-30154) — a supply chain attack that cascaded from one action into thousands of downstream pipelines. This guide walks the GitHub Actions attack surface and the hardening that would have stopped it.
The attack surface
Four properties make Actions a high-value target. First, mutable references: uses: some/action@v4 resolves whatever commit the v4 tag currently points at, and tags are not immutable, so a maintainer (or an attacker who steals their token) can retroactively change what v4 means. Second, ambient secrets: every job gets a GITHUB_TOKEN, and many jobs are handed cloud keys or registry credentials in the environment. Third, untrusted input: workflow triggers like pull_request_target and issue_comment run with write permissions while processing attacker-controlled data such as PR titles and branch names. Fourth, transitive trust: a pinned action can itself call unpinned actions, so your careful pin is only as good as the dependency's.
Hardening step 1: pin every action to a full commit SHA
Version tags float; commit SHAs do not. Pinning to a 40-character SHA means a re-pointed tag cannot change the code you run. Add a comment with the human-readable version so upgrades stay reviewable.
jobs:
build:
runs-on: ubuntu-latest
steps:
# Pinned to an immutable commit SHA, not a mutable tag
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
GitHub's own tooling helps here: actionlint catches workflow errors, zizmor audits for known dangerous patterns, and Dependabot can raise pinned-SHA update PRs. As of 2026, GitHub also supports immutable actions published as OCI packages, which resist tag re-pointing at the registry level — prefer them where a publisher offers them, but SHA-pinning remains the baseline that works for everything.
Hardening step 2: default GITHUB_TOKEN to read-only
The GITHUB_TOKEN defaults to broad permissions in many older repositories. Set the workflow-wide default to read-only and grant write scopes per job, only where needed.
permissions:
contents: read
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # narrowly scoped to the job that publishes
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
A compromised step in a read-only job cannot rewrite your default branch or push a release, which contains the blast radius dramatically.
Hardening step 3: stop script injection
Never interpolate untrusted event data directly into a run: block. The expression is expanded into the shell before it executes, so a crafted PR title can run commands.
# DANGEROUS - do not do this
- run: echo "Title: ${{ github.event.pull_request.title }}"
# SAFE - pass through an environment variable
- run: echo "Title: $PR_TITLE"
env:
PR_TITLE: ${{ github.event.pull_request.title }}
Environment variables are not re-parsed by the shell as code, so metacharacters in the title stay inert. Also avoid pull_request_target unless you fully understand it: it runs with repository write access against the base repo while checking out a fork's code, which is exactly the combination attackers look for.
Secrets and OIDC
The single biggest secrets win is deleting the long-lived cloud keys entirely. GitHub Actions supports OpenID Connect (OIDC), so a job exchanges a short-lived signed token for temporary cloud credentials at runtime — nothing static is stored in the repo.
permissions:
id-token: write # required to request the OIDC token
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-deploy
aws-region: us-east-1
Configure the cloud trust policy to accept the token only from your specific repository and branch (via the sub claim), so a fork or another repo cannot assume the role. For secrets that genuinely must be stored, use environment-scoped secrets with required reviewers, and never echo a secret — the log redaction is best-effort and misses transformed values like base64.
Adding Safeguard scanning to the pipeline
Hardening the workflow does not tell you whether the dependencies it builds are exploitable. Add a scan step that runs the Safeguard CLI on every pull request, so software composition analysis and reachability run before code merges.
security-scan:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Run Safeguard scan
env:
SAFEGUARD_TOKEN: ${{ secrets.SAFEGUARD_TOKEN }}
run: |
curl -sSfL https://get.safeguard.sh/install.sh | sh
safeguard scan --fail-on high --sbom cyclonedx
Safeguard's SCA engine prioritizes findings by whether the vulnerable code path is actually reachable, so the gate fails on the small set of exploitable issues rather than every CVE in the tree. Where a fix exists, Auto-Fix opens the upgrade PR for you.
Hardening checklist
- Pin all third-party actions to full commit SHAs (or immutable OCI actions)
- Set
permissions: contents: readas the workflow default; grant write per job - Route untrusted event data through
env:, never intorun:directly - Avoid
pull_request_target; require approval for first-time-contributor workflows - Replace static cloud keys with OIDC and repo/branch-scoped trust policies
- Use ephemeral runners; never persist credentials on self-hosted runners
- Scan every PR with reachability-aware SCA and fail on exploitable findings
Frequently Asked Questions
Does pinning to a SHA really matter if the action is popular?
Yes — popularity is what makes it a target. The tj-actions incident hit a widely used action precisely because compromising it reached the most downstream repos at once. Repositories pinned to a specific SHA were unaffected by the retroactive tag re-pointing; those on floating tags pulled the malicious code automatically.
Is OIDC available for providers other than AWS?
Yes. GitHub Actions OIDC works with AWS, Google Cloud, Azure, HashiCorp Vault, and any provider that trusts GitHub's OIDC issuer. The pattern is the same: request an id-token, exchange it for a short-lived credential, and scope the cloud trust policy to your repo and branch.
How is Safeguard different from GitHub's built-in scanning?
GitHub's native tooling is solid for secret scanning and Dependabot alerts, but it produces an unranked stream of dependency alerts. Safeguard adds reachability-based prioritization and autonomous fix PRs so you act on what is exploitable. See Safeguard vs GitHub for a feature-by-feature breakdown.
What is the fastest single change to reduce risk?
Setting the default GITHUB_TOKEN permission to contents: read. It is one line, it rarely breaks anything, and it turns a full-write token — the thing an attacker most wants — into a read-only one for every job that does not explicitly need more.
To wire these controls into your own pipeline, see Safeguard CLI, SCA, Auto-Fix, Safeguard vs GitHub, and full setup steps in the documentation at docs.safeguard.sh.