After tj-actions: keeping secrets safe in GitHub Actions

Short answer: any third-party action you run in a job can read every secret that job has. So keep fewer secrets in jobs, pin actions to full commit SHAs (a tag can be moved), prefer short-lived cloud credentials over stored keys, and know how to rotate in a hurry. The check below finds your unpinned actions in a few seconds.

What happened

In March 2025 the widely used tj-actions/changed-files action was compromised. Per the NVD record for CVE-2025-30066, tags v1 through v45.0.7 “were affected on 2025-03-14 and 2025-03-15 because they were modified by a threat actor to point at” a malicious commit, and the flaw let attackers “discover secrets by reading actions logs.”

CISA’s alert (revised 2025-03-19) says the secrets exposed included “valid access keys, GitHub Personal Access Tokens (PATs), npm tokens, and private RSA keys,” that it was patched in v46.0.1, and that the compromise was potentially due to a similar one of reviewdog/action-setup@v1 (CVE-2025-30154), with other reviewdog actions possibly affected. CISA added CVE-2025-30066 to its Known Exploited Vulnerabilities catalog.

The key detail is how it spread: people referenced the action by a tag, and the attacker re-pointed the tags. Nobody changed their workflow; the same text ran different code.

Why one action can take everything

GitHub’s own security guide says it directly: “a compromise of a single action within a workflow can be very significant, as that compromised action would have access to all secrets configured on your repository, and may be able to use the GITHUB_TOKEN to write to the repository.” It also notes that anyone with write access to a repository can read all its secrets, and that automatic redaction in logs “is not guaranteed” because values can be transformed (secure use reference).

Check: which of your actions can be moved under you?

A reference is safe from tag-moving only if it is a full 40-character commit SHA. This script lists the ones that are not (it skips local ./ actions and docker:// references). We ran it on a sample workflow containing a tag-pinned actions/checkout@v4, tj-actions/changed-files@v45, a local action, a docker image and a SHA-pinned setup-node:

#!/bin/sh
grep -rnE '^\s*-?\s*uses:\s*' .github/workflows .github/actions 2>/dev/null \
  | grep -vE 'uses:\s*(\./|docker://)' \
  | grep -vE '@[0-9a-f]{40}(\s|$)'
.github/workflows/ci.yml:7:      - uses: actions/checkout@v4
.github/workflows/ci.yml:8:      - uses: tj-actions/changed-files@v45

To see why a tag is not a pin: git ls-remote https://github.com/actions/checkout refs/tags/v4 refs/tags/v4.2.2 returned two different commits on 2026-11-01 (v4 is a moving major tag). Resolve the version you want to a SHA, pin it, and keep the tag as a comment (# v4.2.2). Note that for annotated tags ls-remote shows the tag object, so ask for refs/tags/<tag>^{} to get the commit. Verify the SHA belongs to the action’s own repository, not a fork.

GitHub’s guide also says that, for repositories that adopt them, workflow dependency lockfiles are “the strongest protection available” and supersede SHA pinning; SHA pinning is the fallback (docs). Use Dependabot or Renovate to update pinned SHAs deliberately instead of by accident.

Four layers that limit the damage

  1. Fewer secrets in jobs. Give a secret only to the step that needs it (step-level env:), not the whole job; a test job rarely needs a deploy key.
  2. No stored cloud keys. GitHub’s docs say OIDC “will let you stop storing these credentials as long-lived secrets” and let workflows authenticate directly to supporting cloud providers (docs). A stolen short-lived token is a smaller incident.
  3. Minimum GITHUB_TOKEN. GitHub recommends default read-only access to repository contents, raised per job where needed.
  4. Know what each secret can do and how to replace it. This is where Dynv helps, within limits:
    • Give CI a read-only service token that opens one service, not your account. Our CI docs show dynv run --require -- npm test, which gives values to that one command rather than exporting them to later steps. Put DYNV_TOKEN in that step’s env:, not the job’s, so an action in another step never sees it.
    • After an incident, rotate everything the job could read with one previewed operation across services (how), starting with credentials that can write or publish.
    • Dynv does not make a compromised action in the same step harmless: anything that runs in a process can read that process’s environment.

If you used an affected action

Rotate every secret that any workflow run could see while the bad version was in play, not just the ones you remember. Search workflow logs from the affected days for secrets that were printed (CISA’s alert and the vendors’ write-ups list indicators). Then pin, and shrink the secrets each job gets. The npm playbook has the same order: clean first, then rotate.

Limits

  • The unpinned-action check is a text match. It will miss actions referenced from reusable workflows in other repositories and does not check the SHAs are legitimate.
  • Pinning prevents silent updates, so you own the job of updating; stale pins accumulate known vulnerabilities.
  • We did not reproduce the compromise; the facts come from the NVD and CISA records linked above.

Facts last verified . Spotted an error? Tell us.

New here? Start here for the guides in a sensible order.