How a workflow run works, what an attacker gets when one goes wrong, and how to lock it down, with most of the attention on version pinning. The Best Practices page has the baseline hardening examples (least-privilege permissions, safe pull_request_target, script injection, OIDC). This page goes further on why those matter and where they fall short.
How a run works
Every workflow run is the same chain. Something fires an event, GitHub picks a workflow file from some ref, hands a runner a token and the secrets the job asks for, and then executes every step in order. Nothing separates your own build script from a third-party action in step three: they share the filesystem, the environment, the network, and the process memory where secrets live.
1
Event
A push, PR, comment, schedule, or API call. Decides who is effectively running your CI.
2
Workflow file
Read from a ref the event decides. For a fork PR that can be the attacker's copy.
3
Runner
A fresh VM (hosted) or your own machine (self-hosted), given a token and the job's secrets.
4
Steps
Your scripts and every third-party action, all in the same process space with the same access.
5
Side effects
Pushes, releases, packages, cache entries, artifacts, cloud deploys. The real blast radius.
Security questions map onto that chain. Who can fire the event? Which copy of the workflow runs? What does the token let the job do? Which code, from whom, runs in the steps? And what can the job change afterwards? Almost every Actions incident on this site is one of those questions answered badly.
Triggers and who controls them
The trigger is the trust decision. It decides whether an outsider can start the run, whether their copy of the workflow is used, and whether the run gets secrets. The dangerous combination is always the same: a trigger that runs with secrets, plus a step that runs or reads something an outsider controls.
Trigger
Who can fire it
Workflow file from
Fork run gets
Risk
Watch for
pull_request
Anyone who can open a PR, including from a fork
The PR's merge commit, so the PR author controls the workflow file
Read-only GITHUB_TOKEN, no secrets
low
Safe by design for forks. The risk is a same-repo branch PR, which gets secrets, or a later privileged workflow that trusts this run's output.
pull_request_target
Anyone who can open a PR, including from a fork
The default branch (since Dec 2025)
Secrets and the repo's default token permissions
high
Only dangerous once the job checks out or runs PR code. That is the classic pwn request. It also shares the default branch's cache scope.
workflow_run
Whoever triggered the upstream workflow, which can be a fork PR
The default branch
Secrets and write token, even when the upstream run came from a fork
high
Artifacts, branch names, and PR titles from the upstream run are attacker-controlled. Treat anything downloaded from it as untrusted input.
issue_comment / issues / discussion
Anyone who can comment, on public repos that is everyone
The default branch
Secrets and write token
high
Titles, bodies, and comments go straight into expressions. ChatOps bots that check out a PR on a '/test' comment are pwn requests by another name.
push
Anyone with write access to the branch
The pushed commit
Not applicable, forks can't push
medium
A write-access user can add a new workflow on any unprotected branch and read every secret it references. Write access is effectively secrets access unless deploy secrets live in protected environments.
workflow_dispatch / repository_dispatch
Write access, or any token with actions: write / contents: write
The ref the caller chooses
Not applicable
medium
A stolen token with write access can start a release workflow on a branch it just pushed. Inputs are attacker-controlled strings.
schedule
Nobody directly, it runs on a timer
The default branch
Not applicable
low
Low exposure, but these jobs often hold broad tokens for maintenance and quietly keep running long after anyone looks at them.
Two things that catch people out. First, a pull request from a branch in the same repository is not a fork PR, so it gets secrets. Anyone with write access can open one with a modified workflow. Second, the privileged triggers (pull_request_target, workflow_run, issue_comment) are fine on their own. They become a pwn request when a step checks out the PR, restores a cache the PR could have written, or puts a title or comment into a shell.
What an attacker gets inside a job
Once untrusted code runs in a step, whether through a compromised action, a malicious dependency, or script injection, it has everything the job has. Assume all of this is gone:
The GITHUB_TOKEN
With whatever permissions the job was given. On actions/checkout before v6 it was also written to .git/config, where any later step or uploaded artifact could pick it up (the ArtiPACKED finding).
Every secret the job references
The runner process holds them in memory in plain text. Log masking doesn't help: the tj-actions payload read them straight out of the Runner.Worker process memory.
An OIDC token, if id-token: write is set
Which can be traded for cloud credentials for as long as the job runs, from anywhere.
Credentials set up by earlier steps
Cloud CLI sessions, registry logins, npm/pypi tokens in config files. The environment is richer than the YAML suggests.
The Actions cache
A job can write entries that later, more privileged runs on the same branch scope restore. That is how Ultralytics was poisoned.
Outbound network
Hosted runners have unrestricted egress by default, so exfiltration is one curl away.
The runner host itself (self-hosted only)
A non-ephemeral runner keeps whatever the attacker leaves behind, for every future job that lands on it, and sits inside your network.
The takeaway is that the blast radius of a job is decided before it starts: by its token permissions, the secrets it references, its OIDC trust, and the runner it lands on. Reducing any of those helps even when every other control fails.
GITHUB_TOKEN scopes by tier
Each scope you add to a job's permissions: block is handed to every step in that job. These are the permissions a workflow can ask for, from the Permission Reference, on the same scale as everything else. The token only reaches the repository the workflow runs in and expires when the job ends (at most 24 hours), so none of them is Tier 0 on its own.
Starts, re-runs and cancels workflows and deletes caches and artifacts. Starting a privileged publish or deploy job by hand was the step that mattered in the Nx incident.
Lets the job request an OIDC token and trade it for cloud credentials. The cloud trust policy is the only check left; a wildcard subject turns this into cloud admin.
Pushes commits, creates and moves branches and tags, and edits releases. Cannot change workflow files without Workflows: write. On a repository other projects consume, moving a tag changes what they run.
A workflow with no permissions: key falls back to the repository default, which on older orgs is often read/write on nearly everything. Setting the org default to read-only and writing an explicit block per job is the single cheapest change on this page.
Version pinning
A uses: line is a dependency on someone else's repository, fetched fresh on every run. What you pin to decides who can change the code your job runs, and whether you would notice.
Reference
Example
Who can change what runs
Risk
Branch
actions/checkout@main
Anyone who can push to that branch in the action's repo, at any time
high
Tag
actions/checkout@v6
Anyone with write access to the action's repo. Tags can be moved or recreated silently, which is what tj-actions and TeamPCP did
high
Tag on an immutable release
owner/action@v2.1.0 (release published as immutable)
Nobody, the tag is locked to its commit and can't be deleted. Floating major tags like v2 are still mutable
medium
Full commit SHA
actions/checkout@<40-char sha> # v6.0.1
Nobody. Changing the code means a new SHA, which means a visible diff in your repo
low
Pin to a SHA and keep the version as a comment
GitHub's own guidance calls a full-length SHA the only way to use an action as an immutable release. The trailing comment is what makes pins reviewable and lets Dependabot and Renovate update them.
For the first pass across many repositories, a tool like pinact rewrites tag references to SHA plus comment in place. After that, let Dependabot keep them current:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/" # covers .github/workflows
schedule:
interval: "weekly"
groups:
actions:
patterns: ["*"] # one PR per week instead of one per action
Review those PRs like code, because they are code. A bump PR that is auto-merged pins a malicious release for you just as fast as a floating tag would pull it in. Look at the upstream diff for anything that touches network calls, credentials, or new nested uses: lines.
Enforce it, don't just recommend it
Since August 2025 the Actions policy at enterprise, org, and repo level has a checkbox that fails any workflow using an action not pinned to a full SHA. The same allow-list accepts ! entries to block a specific action or version outright, which is the fastest way to cut off a compromised action across an org while you work out who used it:
# Settings > Actions > General > Allow select actions and reusable workflows
your-org/*,
aws-actions/configure-aws-credentials@*,
docker/build-push-action@*,
!tj-actions/changed-files@*
Scan first, then enforce. Run a scanner across the org to find unpinned uses, fix them, then turn on the SHA requirement. Turning it on first breaks every unpinned workflow on its next run.
What a SHA pin does not cover
Nested actions inside composite actions
A composite action you pinned by SHA can itself call other actions by tag. You get whatever those tags point to today. When reviewdog/action-setup@v1 was backdoored in March 2025, other reviewdog actions that call it by tag passed the payload on, including to workflows that had pinned those actions by SHA.
Docker-based actions
An action whose action.yml says `image: docker://some/image:latest`, or whose Dockerfile starts `FROM some/image:tag`, pulls a mutable image on every run.
Things the action downloads at runtime
Setup actions that fetch 'the latest' binary, install scripts piped from curl, and unpinned package installs. The SHA pins the wrapper, not what it fetches.
Reusable workflows
`uses: org/repo/.github/workflows/build.yml@main` is a mutable reference to a whole job, secrets included if you pass `secrets: inherit`.
Impostor commits
GitHub resolves a SHA from any fork in the repository's network, so `owner/action@<sha>` can point at a commit that only exists in an attacker's fork. Check that a new SHA is reachable from a tag or branch in the real repo.
Auto-merged bump PRs
If Dependabot or Renovate PRs for actions merge without review, a malicious release gets pinned for you. Pinning buys review time only if someone uses it.
GitHub's 2026 roadmap targets the first of these directly: a dependencies: section in the workflow file that locks every direct and nested action to a SHA, so composite actions no longer hide what they pull in. Until that is available to you, prefer actions that pin their own dependencies (OpenSSF Scorecard's Pinned-Dependencies check shows this), and vendor small actions into your own org where you can.
If you publish actions yourself
Turn on immutable releases, so a published tag is locked to its commit and can't be deleted or moved.
Add a tag ruleset that blocks force-updates and deletion for everyone, including admins.
Pin every nested uses: and Docker image inside your action by SHA or digest, so your consumers' pins mean something.
Publish from a workflow that runs no untrusted code, with a token scoped to that repository only.
Hardening, layer by layer
Controls that are set once for the whole org beat controls that rely on every workflow author getting it right. Work from the top down.
1. Org and enterprise policy
Default GITHUB_TOKEN permission set to read-only.
"Allow GitHub Actions to create and approve pull requests" turned off.
Allowed actions restricted to your org, GitHub-verified creators, and a reviewed allow-list, with SHA pinning required.
Approval required for workflow runs from outside contributors on public repos.
Self-hosted runners limited by runner group to the repos that need them, and never available to public repos.
Workflow execution protections to limit who can run release and deploy workflows, and to disallow pull_request_target where nothing needs it. Start in evaluate mode.
2. The workflow file
permissions: {} at the top, then grant per job.
persist-credentials: false on checkout unless the job pushes.
No ${{ }} expressions with event data inside run:. Pass them through env:.
Deploy secrets in environments with required reviewers and a branch rule, not as repo or org secrets.
OIDC for cloud access, with the trust policy pinned to repo and environment.
No secrets: inherit into reusable workflows. Pass the ones they need by name.
Separate caches for release builds, or no cache at all in jobs that publish.
Ephemeral or just-in-time self-hosted runners, one job per machine, rebuilt from an image.
Runners grouped by trust level. A runner that builds public PRs never shares a group with one that deploys.
Egress control. On hosted runners that means an agent such as Harden-Runner today, or GitHub's native egress firewall as it rolls out. Start by recording traffic, then block.
4. Detection and review
Scan workflow files on every pull request, the same way you scan code. These catch most of the patterns on this page:
The Tooling page goes through each of these and the rest of the open-source options, with how they work and how to run them in CI.
Require code owner review on .github/workflows/ and .github/actions/.
Stream the audit log out of GitHub and alert on workflow file changes, new self-hosted runners, secret changes, and deleted run logs.
Keep a list of which repos use which actions. When the next tj-actions happens, the first question is "where do we use it", and the answer should take minutes.
The Checklist tracks the org-level items above under "CI/CD & GitHub Actions".
What GitHub changed recently
Several defaults changed between 2025 and 2026. If your threat model or internal guidance predates these, parts of it are out of date. Roadmap items move, so check the linked changelog before relying on one.
Prefix an allow-list entry with ! to block an action or version. A separate checkbox fails any workflow that uses an action not pinned to a full SHA. Available at enterprise, org, and repo level.
Old, vulnerable copies of a workflow on other branches can no longer be triggered by targeting a PR at that branch. Environment branch rules now evaluate against the ref that actually runs.
Workflow dependency locking, scoped secrets, execution policies, an Actions data stream, and a native egress firewall for hosted runners. Most items were slated for preview within 3 to 9 months.
Checking out a fork PR in pull_request_target or workflow_run fails unless you set allow-unsafe-pr-checkout. Backported to all supported major versions on July 20, 2026.
Allow-lists for who can trigger a workflow and which events may start it, scoped down to individual workflow files, with an evaluate mode and a REST API.
One side effect worth knowing: the checkout v7 fork guard was backported to older major tags, so workflows on actions/checkout@v4 got it automatically, while workflows pinned to an old SHA did not. Pinning means you also have to take security fixes deliberately.
Incidents on this site
Real cases behind the sections above, each with a step-by-step walkthrough and mitigations.
The attacker used a previously obtained GitHub token with write permissions to push a commit disguised as renovate[bot], slipped into a legitimate auto-merged PR, then retroactively repointed multiple version tags at the malicious commit, affecting over 23,000 repositories. The payload dumped runner memory to logs, exposing access keys, PATs, npm tokens, and private RSA keys. Upstream, reviewdog/action-setup (CVE-2025-30154) was backdoored first, and researchers traced the original target to Coinbase's agentkit repo. The permission abused, contents:write on an Action repo, implies tag-write, which implies control of every consumer pinning by tag.
Click to open →
OrganizationMarch – April 2026
TeamPCP tag force-overwrite campaign
The most complete demonstration of tag-write as a permission tier. On 19 March 2026 (CVE-2026-33634), continuing an attack that began in late February, the actors used credentials with tag-write privileges to publish Trivy v0.69.4 and force-overwrite 76 of 77 tags in aquasecurity/trivy-action and all seven in aquasecurity/setup-trivy, redirecting trusted versions to malicious commits with no visible change in release metadata. Checkmarx's KICS GitHub Action followed on 23 March, when all 35 of its tags were moved through a compromised service account, and on 24 March LiteLLM versions 1.82.7 and 1.82.8 were uploaded straight to PyPI with credentials taken from LiteLLM's own CI, where Trivy ran. Researchers traced at least seven waves through to @bitwarden/cli hijacked with stolen KICS npm tokens, and elementary-data via Actions script injection. Each compromised environment yielded the credentials for the next.
The structural flaw behind most of the incidents on this page. pull_request deliberately withholds write permissions and secrets; pull_request_target was created to restore them, and becomes hazardous the moment maintainers treat it as a normal CI trigger. workflow_run carries the same risk and gets far less attention: the triggered workflow can access secrets and write tokens even when the workflow that triggered it could not. One industry scan found 38% of organizations have a workflow vulnerable to script injection or dangerous triggers. Since 8 December 2025, pull_request_target always runs the workflow file from the default branch and checks out the default branch, whatever branch the pull request targets. That closes attacks through an old, vulnerable workflow left on another branch. It does not help when the default branch workflow itself checks out and runs the pull request's code.
Click to open →
OrganizationDecember 2024
Ultralytics build cache poisoning
Template injection via a crafted branch name against a format.yml workflow using pull_request_target. Secrets including secrets._GITHUB_TOKEN were exfiltrated and the build cache poisoned to inject a cryptominer into v8.3.41 and v8.3.42. The read from researchers was that the attackers had write access but preferred extracting CI/CD secrets, then used them to poison the cache and ship malicious releases.
Click to open →
OrganizationOngoing
Actions cache poisoning (Angular, TanStack)
Actions caches are shared between PR workflows and release workflows. One researcher's angular/dev-infra chain was script injection via ${{ github.head_ref }} in a pull_request_target workflow, then cache filling with a purpose-built tool. TanStack saw 84 malicious versions across 42 @tanstack/* packages from the same class of chain on 11 May 2026: a pull_request_target workflow ran a fork's code, which poisoned the pnpm store in the shared cache, and the release workflow restored it and leaked its npm OIDC token. There is no CVE for any of this; the cache is behaving exactly as designed.
Click to open →
ApplicationAugust 2024
ArtiPACKED artifact token leakage
actions/checkout persists the GitHub token into .git/config by default; uploading the whole checkout directory as an artifact exposes it. Researchers observed leakage of both GITHUB_TOKEN and the undocumented ACTIONS_RUNTIME_TOKEN, which lives about six hours and can be used to swap an artifact for a malicious version. The token-theft path is a race condition: download the artifact mid-run and create a branch before expiry. It requires the workflow to hold contents: write.
Click to open →
OrganizationSeptember 2025, returned 2026
GhostAction
327 compromised GitHub user accounts were used to inject workflows disguised as "Add GitHub Actions Security workflow" across 817 repositories, stealing 3,325 secrets. The actors enumerated secret names from legitimate workflow files first, then hardcoded those names into their own workflow, harvesting PyPI, npm, DockerHub, GitHub, Cloudflare, and AWS credentials. It came back in 2026. GitGuardian found the malicious workflow in 772 public repositories belonging to 373 users and organizations between 31 August and 30 September 2026, aimed at 2,577 secrets; GitHub held most of the runs for approval, and by 5 October only 16% of the repositories had removed the workflow. From 7 October Socket saw more than 500 accounts committing it to tens of thousands of repositories.
Click to open →
OrganizationAugust 2025
Nx "s1ngularity"
The injected script extracted a read/write GitHub token, a consequence of the workflow permissions setting, then created a branch replacing the CI script, triggered the publish workflow against that branch via the API using workflow_dispatch, and deleted branches and workflow runs to clean up. When publish ran, it had NPM_TOKEN in scope and exfiltrated it. The PR targeted an outdated branch that still carried the vulnerable workflow after it had been reverted on master. In phase two, the stolen GitHub tokens were used to flip thousands of private repositories to public. Downstream, one threat-intel group rode the consequences to full AWS administrator access at a victim org in under 72 hours, via OIDC trust chains and a CloudFormation IAM capability chain. No zero-days were involved.
Click to open →
Organization2023–2024
Self-hosted runner takeovers (PyTorch and others)
A fork PR makes you a "previous contributor," after which workflows run on the self-hosted runner without approval; default runner setup is non-ephemeral, so access persists. On PyTorch, researchers confirmed root on the runners and pulled GITHUB_TOKENs with extensive write access out of .git/config during active workflows. Against GitHub's own actions/runner-images, the access reached internal macOS private cloud vCenter and Azure credentials, and could have poisoned every hosted runner base image. TensorFlow's fix was requiring approval for all fork PRs and setting GITHUB_TOKEN to read-only on self-hosted runners.
Click to open →
OrganizationFebruary 2026
hackerbot-claw autonomous agent RCE
An autonomous agent doing this at scale. It abused unsafe pull_request_target, unsanitized inputs, dynamic shell execution, and overprivileged GITHUB_TOKEN to get RCE on GitHub-hosted runners across at least six repositories, exfiltrating write-capable tokens in some cases. Targets included Trivy, avelino/awesome-go, microsoft/ai-discovery-agent, DataDog/datadog-iac-scanner, project-akri/akri, RustPython, and ambient-code/platform, where the vector was prompt injection via CLAUDE.md, which was blocked. On Trivy it stole a PAT through a pull_request_target workflow and used it to take over the repository.