GitHub Actions Security
GitHub Actions security controls which code a workflow runs and what it can reach, so untrusted inputs and actions never act with a repo's token or secrets.
Also known as GitHub workflow security, GHA security, GitHub Actions hardening, pull_request_target security, securing GitHub Actions workflows, GitHub Actions supply chain security
GitHub Actions security is the practice of controlling which code a GitHub workflow runs and what that code can reach. Each job runs trigger inputs, third-party actions, and dependencies on one runner holding the repository's GITHUB_TOKEN and secrets, so one untrusted input, such as a moved tag, can act as you. It is also called GitHub workflow security.
GitHub Actions security comes down to three decisions the platform makes before your first step runs: which event fired, which code each uses: line resolves to, and which credentials the job receives. Each compromise in this entry broke one of the three. The tj-actions/changed-files compromise moved tags on an action used by more than 23,000 repositories. The Nx compromise turned a pull request title into shell code. The TanStack compromise minted a publishing credential from inside a release workflow while its tests ran. Our position: treat every uses: line as a dependency and every ${{ }} expression as untrusted input, and do not confuse pinning with review. As of October 2026, GitHub ships controls that fix which code runs and who can trigger it. None of them tells you what that code does.
How a GitHub Actions run decides what to trust
#A workflow run is a chain of resolutions, and each link is a trust decision.
- An event fires:
push,pull_request,pull_request_target,issue_comment,workflow_run, a schedule. The event decides which copy of the workflow file runs, which commit is checked out by default, and how much authority the run gets. - At job setup, the runner resolves every
uses:reference to a commit and downloads that code, including the actions a composite action calls in turn. A tag such as@v4resolves at that moment, not when someone reviewed the workflow. - The platform mints a
GITHUB_TOKENscoped to the repository and injects the secrets the job references. A job grantedid-token: writecan also request an OpenID Connect (OIDC) token that cloud providers and registries accept as identity. - Steps run as ordinary processes on one machine, with one filesystem and one set of credentials.
Step 4 is why a GitHub Actions job is only as trustworthy as the least trustworthy code it runs. An action, a test, and a dependency's install script all share the job's credentials, and nothing inside the job separates them. The general pattern across CI systems is covered under CI/CD security. This entry covers the GitHub-specific mechanics: triggers, expressions, action references, and the job's credentials.
Which GitHub Actions triggers are dangerous?
#The dangerous GitHub Actions triggers are the ones that hand out the base repository's credentials in response to something an outsider controls: pull_request_target, issue_comment, and workflow_run downstream of a fork's pull request. The safe default, pull_request, runs a fork's code with a read-only token and no secrets. GitHub built the privileged triggers for automation that needs write access, such as labeling a fork's pull request. GitHub's documentation for pull_request_target says the trigger is safe only when the checked-out code is never executed.
| Trigger | Workflow file and default checkout | GITHUB_TOKEN and secrets | Verdict |
|---|---|---|---|
| pull_request from a fork | The pull request's merge commit | Read-only token, no secrets | Safe place to build and test outsider code |
| pull_request_target | Default branch (since December 8, 2025) | Base repository's token and secrets; cache is read-only | Labeling and triage only. Never install, build, or run pull request code |
| issue_comment | Default branch | Base repository's token and secrets | Same rules as pull_request_target: any GitHub user can comment on a public repository |
| workflow_run after a pull request | Default branch | Secrets and a write-capable token, even if the triggering run had neither | Treat artifacts and outputs from the triggering run as untrusted data |
| push and schedule | The pushed commit, or the default branch | Full token and secrets | Trusted only as far as the accounts that can push. GhostAction shows the limit |
Read the table as a list of who carries the risk. In the pull_request row, the platform withholds credentials. In the other rows, the platform hands them over and trusts the workflow author to keep outsider code out of the job. That trust has failed repeatedly. In the Nx compromise, an injectable pull_request_target workflow had been removed from master, but an outdated branch still carried it, and the attacker opened a pull request against that branch. GitHub closed that path on December 8, 2025, when pull_request_target began always running the default branch's workflow.
Two newer defaults narrow the trigger further. In June 2026, actions/checkout v7 began refusing to check out a fork's pull request code inside pull_request_target and pull-request-driven workflow_run runs. The guard covers only actions/checkout, not git fetch, gh pr checkout, or issue_comment workflows. Then workflow execution protections, generally available since September 17, 2026, added a default rule that disables pull_request_target on public repositories with no event policy of their own. The rule starts in evaluate mode, and GitHub's changelog says it will be enforced automatically on December 2, 2026. If a workflow genuinely needs the trigger, allow it for that one workflow file and keep that file free of checkouts, installs, and caches.
How does expression injection work in GitHub Actions?
#Expression injection in GitHub Actions happens because the runner substitutes ${{ }} expressions into a script's text before the shell parses it. The shell never sees a variable, only the resulting string. When the expression holds attacker-controlled text, such as a pull request title, an issue body, or a branch name, that text becomes part of the program. It is the Actions form of code injection.
The Ultralytics compromise of December 2024 is the case worth studying, because the bug was not in Ultralytics' workflow file. Their pull_request_target workflow called a composite action, and William Woodruff's analysis traced the likely injection point to a step inside that action's action.yml:
# Composite action called by a pull_request_target workflow (excerpt)
- name: Commit and Push Changes
shell: bash
run: |
git pull origin ${{ github.head_ref || github.ref }}github.head_ref is the name of the branch a pull request comes from, and anyone who opens a pull request chooses it. The attacker opened one from a crafted branch name, gained code execution in a privileged job, and used it to poison the build cache that the release workflow later restored. Ultralytics 8.3.41 and 8.3.42 reached PyPI carrying a cryptominer, with valid attestations. Woodruff also noted that his own workflow linter flagged the dangerous trigger but missed this line, because it did not audit composite actions at the time. Third-party actions are code your workflow executes, and they need the same scrutiny as your workflow file.
The fix moves the value out of the script text and into the environment, where the shell treats it as data:
- name: Commit and Push Changes
shell: bash
env:
HEAD_REF: ${{ github.head_ref || github.ref }}
run: |
git pull origin "$HEAD_REF"The environment-variable pattern fixes shell injection only. If the step, or an action it calls, later evaluates the variable, builds a command string from it, or passes it to a later step as an output, the value is still untrusted. That is why the Socket alerts described below track three kinds of sink, not one.
Why should you pin GitHub Actions to a commit SHA?
#You should pin GitHub Actions to a full commit SHA because a tag is a movable pointer that the runner resolves every time a job starts. Anyone with push access to the action's repository, including an attacker holding a stolen maintainer credential, can repoint v4 at new code. Every workflow that references @v4 then runs it on its next trigger. You can watch the resolution yourself. We ran this against actions/checkout on October 27, 2026:
$ git ls-remote https://github.com/actions/checkout \
refs/tags/v7 refs/tags/v7.0.1 refs/tags/v6.0.3 'refs/tags/v6.0.3^{}'
9f698171ed81b15d1823a05fc7211befd50c8ae0 refs/tags/v6.0.3
df4cb1c069e1874edd31b4311f1884172cec0e10 refs/tags/v6.0.3^{}
3d3c42e5aac5ba805825da76410c181273ba90b1 refs/tags/v7
3d3c42e5aac5ba805825da76410c181273ba90b1 refs/tags/v7.0.1Two details in that output matter. First, v7 and v7.0.1 resolve to the same commit today, and nothing stops v7 from moving tomorrow. Second, v6.0.3 is an annotated tag: the first hash is the tag object, and the ^{} line is the commit it points to. Pin the commit, and keep the tag as a comment so update tools can still read it:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1Tag movement is how three of the largest Actions compromises spread:
- In March 2025, the tj-actions/changed-files compromise repointed tags
v1throughv45.0.7at a commit that printed CI secrets into workflow logs (CVE-2025-30066). - In March 2026, the Trivy GitHub Actions compromise force-pushed 75 of 76 version tags on aquasecurity/trivy-action, which more than 10,000 workflow files referenced. Each forged commit copied the original's author, timestamps, and message, and GitHub's releases page still showed an "Immutable" badge next to the poisoned tags.
- In September 2026, the actions-cool re-enablement brought back two actions that GitHub had disabled during the Mini Shai-Hulud campaign, with their malicious tags intact. About 15,000 repositories depend on actions-cool/issues-helper alone, and tag-referencing workflows ran the payload again on their next run, with no new attacker action.
Figure 1 shows what each kind of reference runs after an attacker force-pushes a tag. A tag reference such as uses: org/act@v2 follows the tag to the attacker's commit. A full commit SHA such as uses: org/act@a1b2… keeps running the reviewed commit. A lockfile entry also runs the reviewed commit, and additionally records the source repository's numeric ID, so the runner can tell when the name now points at a different repository.
SHA pinning has costs that pinning guides rarely mention. A pin freezes out fixes as well as attacks: GitHub scheduled the checkout v7 protection to be backported to supported major versions on July 16, 2026, so workflows on a floating @v4 receive it automatically and SHA-pinned workflows do not. A pin on the outer action also leaves any tag references inside a composite action floating. And a pin says nothing about safety. A SHA pinned to a malicious commit is reproducibly malicious, so every pin update needs the same review as the first pin.
Workflow dependency lockfiles, in public preview since October 14, 2026, close most of those gaps. They apply the idea behind a package lockfile to the actions layer. A .github/workflows/actions.lock file, generated by the gh actions-lock extension, records the resolved commit for every action, transitive ones included, plus the source repository's numeric owner and repository IDs. At run time, Actions runs the locked commit and checks that the repository behind the name is still the one that was locked, which is the check that defeats repojacking of a renamed or deleted action. "This is the strongest protection available, and supersedes SHA pinning for repositories that adopt it," says GitHub's secure use reference. The lockfile documentation is equally plain about the limit: locking does not establish whether a dependency is safe.
| Reference | Guarantees | Misses | Cost |
|---|---|---|---|
| Tag (@v4) | Nothing. Resolved at every job start | Every moved tag: tj-actions, trivy-action, actions-cool | None, and backported fixes arrive automatically |
| Full commit SHA | The same top-level commit on every run | Tags inside composite actions; whether the commit is safe; backported fixes | An update bot plus a review on every bump |
| Lockfile (actions.lock) | Locked commits for direct and transitive actions, plus source repository identity | Whether the locked commit is safe; dependencies of reusable workflows defined in other repositories | Public preview; each workflow is onboarded individually |
| Allowed-actions policy | Org-wide enforcement of SHA pins and a blocklist | Code review of anything it allows | Admin setup; blocking is reactive |
Our verdict: adopt a lockfile where your plan supports it, pin full SHAs everywhere else, and enforce the choice with the allowed-actions policy, which can require full-SHA pins and block specific actions since August 15, 2025. Never reference a third-party action by tag from a job that can see a secret. Then review what you lock.
What a compromised step can reach
#A compromised step can reach everything its job holds: the GITHUB_TOKEN and its permissions, every secret the job references, the cache, and any OIDC identity the job may request. Scope is set per job, not per step, so the most effective control is least privilege at the job level:
permissions: {} # nothing by default
jobs:
test: # runs code that changes often; holds nothing
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: npm ci --ignore-scripts && npm test
publish: # holds the OIDC grant; runs no tests
needs: test
runs-on: ubuntu-latest
environment: release # require a reviewer on this environment
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
registry-url: https://registry.npmjs.org
- run: npm ci --ignore-scripts && npm publishpermissions: {} at the top makes every job ask for what it needs. GitHub made the GITHUB_TOKEN read-only by default on February 2, 2023, but only for newly created enterprises, organizations, and personal repositories. Older organizations kept read/write, and new repositories inherit their organization's setting, so an explicit block is the only way to know what a job gets.
The GITHUB_TOKEN is a credential, not a formality. In the Nx compromise, the injected command used a pull request workflow's token to trigger the separate publish.yml workflow, which held the npm token, and that npm token was sent to a webhook. Malicious Nx versions reached npm on August 26, 2025.
Short-lived identity removes the token an attacker could steal, not the job an attacker could hijack. After its compromise, Nx moved every package to npm trusted publishing, which exchanges the job's OIDC token for a short-lived publish credential. The TanStack compromise in May 2026 showed the limit. Attacker code running inside TanStack's release workflow obtained that workflow's OIDC token from the runner and published 84 malicious versions across 42 @tanstack/* packages, without stealing a single npm token. The publish job in the example above runs no tests for that reason, but it still installs dependencies next to the OIDC grant. Every control here narrows the gap rather than closing it.
Some attacks never touch your workflow logic. In the October 2026 GhostAction wave, two compromised maintainer accounts committed a workflow named security-audit.yml to 346 repositories, and by October 9 we had counted more than 500 accounts committing the same file. The payload was a new workflow, not a bug in an existing one, so permissions in other workflows did nothing. That is a maintainer account takeover, and the controls that help sit on the account and the repository: phishing-resistant multi-factor authentication, required review for changes under .github/workflows/, and execution protections that restrict which actors can run workflows at all.
How do you secure GitHub Actions workflows?
#You secure GitHub Actions workflows by making each job a deliberate trust boundary. Jobs that run outsider code get no secrets, no write token, no cache writes, and no OIDC. Jobs that hold credentials run only code you have pinned and reviewed. Each control below closes one bridge and leaves another open:
| Control | Closes | Leaves open |
|---|---|---|
| Build and test outsider code under pull_request; keep pull_request_target for labeling | Pwn requests like Nx and TanStack | issue_comment workflows; git fetch or gh pr checkout in a privileged job |
| Pass outsider text through env:, never ${{ }} in run: | Shell injection from titles, bodies, and branch names | Actions that evaluate the value or pass it on as an output |
| Lockfile or full-SHA pins, enforced by policy | Moved tags: tj-actions, trivy-action, actions-cool | Malicious code at the locked commit |
| permissions: {} with per-job grants | Write-token abuse, as in Nx's trigger of publish.yml | Secrets the job references; cache writes |
| id-token: write on one job behind a reviewed environment | Theft of long-lived publish tokens | Code running inside that job, as in TanStack |
| cache-mode: none on jobs that hold credentials | Cache poisoning, as in Ultralytics and TanStack | Artifacts passed between jobs |
| Workflow execution protections | Untrusted actors and events starting runs | A stolen maintainer account that the rules allow, as in GhostAction |
| Scanning the actions you reference | Malicious or unsafe action code before you lock it | A tag moved between scans |
Read-only cache tokens for untrusted triggers became the default in June 2026, and per-job cache-mode shipped in September 2026 with four values: read, write, write-only, and none. If you adopt only three rows of the table, adopt explicit permissions, locked or SHA-pinned actions, and the rule that no privileged trigger ever executes pull request code.
What Socket checks in a GitHub Action
#Socket scans the actions a workflow references, and the composite actions those actions call, as dependencies. Many actions ship bundled JavaScript, so Socket statically analyzes that code for malware, obfuscation, shell access, and exfiltration paths, as it does for npm packages. It also runs Argus, an open source taint-tracking engine from North Carolina State University and Purdue University, to follow untrusted values through an action. The result is seven GitHub Actions alerts:
| Alert | Untrusted source | Where it lands |
|---|---|---|
| ghaContextToSink | Workflow context, such as an issue title or pull request body | A dangerous sink such as shell execution |
| ghaArgToSink | An action input passed with with: | A dangerous sink such as shell execution |
| ghaEnvToSink | An environment variable | A dangerous sink, exploitable when the variable's value is untrusted |
| ghaContextToEnv | Workflow context | An environment variable exported to later steps |
| ghaContextToOutput | Workflow context | An action output consumed by later steps or jobs |
| ghaArgToEnv | An action input | An environment variable exported to later steps |
| ghaArgToOutput | An action input | An action output consumed by later steps or jobs |
Figure 2 draws the same seven alerts as edges. Three untrusted sources (workflow context, action inputs, and environment variables) appear on the left. Three sinks (dangerous execution, exported environment variables, and outputs) appear on the right. Context and action inputs each have an edge to all three sinks. Environment variables have one edge, to dangerous execution.
The alerts map onto the incidents above. The Ultralytics bug was a context value reaching a shell inside a composite action, the case ghaContextToSink describes, and Socket's remediation for that alert is the environment-variable fix shown earlier. ghaEnvToSink covers the case where that fix is not enough, because the action later executes the variable's value. The environment and output alerts cover the quieter case: an action that never runs the value itself but hands it to whichever step or job consumes it next.
Two limits apply. Socket resolves each tag to the commit it points to at scan time, so a tag moved after a scan is flagged on the next scan, not at the moment a runner resolves it. And a data-flow alert describes a path, not an exploit: it matters when an outsider controls that value in the trigger your workflow uses. GitHub Actions scanning launched in October 2025 as an experimental release for Business and Enterprise plans.
Where GitHub Actions security defaults stop
#Between August 2025 and October 2026, GitHub added SHA-pin enforcement, default-branch pull_request_target, checkout v7's fork guard, read-only caches for untrusted triggers, cache-mode, execution protections, and lockfiles. Each control answers one of two questions: which code runs, or who and what may start a run. None of them looks inside the commit.
The Trivy forgeries were valid git commits with copied metadata, and the actions-cool payload sat behind tags nobody had touched in four months. A lock taken after either compromise would have preserved the malicious commit exactly. TanStack's malicious versions went out through the project's own trusted publisher binding. Deciding whether the code behind a reference is safe, before it is locked and before a privileged job runs it, is the step that most pipelines still skip.
