# 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

Source: https://socket.dev/glossary/github-actions-security

Last updated: 2026-10-27

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](https://socket.dev/blog/github-actions-supply-chain-attack-puts-thousands-of-projects-at-risk) moved tags on an action used by more than 23,000 repositories. The [Nx compromise](https://socket.dev/blog/nx-supply-chain-attack-investigation-github-actions-workflow-exploit) turned a pull request title into shell code. The [TanStack compromise](https://socket.dev/blog/tanstack-npm-packages-compromised-mini-shai-hulud-supply-chain-attack) 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.

1. 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.
2. 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 `@v4` resolves at that moment, not when someone reviewed the workflow.
3. The platform mints a `GITHUB_TOKEN` scoped to the repository and injects the secrets the job references. A job granted `id-token: write` can also request an [OpenID Connect (OIDC)](https://socket.dev/glossary/openid-connect-oidc) token that cloud providers and registries accept as identity.
4. 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](https://socket.dev/glossary/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](https://docs.github.com/en/enterprise-cloud@latest/actions/reference/security/securely-using-pull_request_target).

| 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](https://github.blog/changelog/2025-11-07-actions-pull_request_target-and-environment-branch-protections-changes/).

Two newer defaults narrow the trigger further. In June 2026, [actions/checkout v7 began refusing to check out a fork's pull request code](https://socket.dev/blog/github-actions-checkout-blocks-pull-request-target-checkouts) 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](https://github.blog/changelog/2026-09-17-workflow-execution-protections-in-github-actions-generally-available/), 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](https://socket.dev/glossary/code-injection).

The [Ultralytics compromise](https://socket.dev/blog/ultralytics-pypi-package-compromised-through-github-actions-cache-poisoning) 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](https://blog.yossarian.net/2024/12/06/zizmor-ultralytics-injection) traced the likely injection point to a step inside that action's `action.yml`:

```yaml
# 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:

```yaml
- 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:

```shell
$ 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.1
```

Two 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:

```yaml
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
```

Tag movement is how three of the largest Actions compromises spread:

- In March 2025, the tj-actions/changed-files compromise repointed tags `v1` through `v45.0.7` at a commit that printed CI secrets into workflow logs (CVE-2025-30066).
- In March 2026, the [Trivy GitHub Actions compromise](https://socket.dev/blog/trivy-under-attack-again-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](https://socket.dev/blog/mini-shai-hulud-actions) brought back two actions that GitHub had disabled during the [Mini Shai-Hulud](https://socket.dev/blog/tanstack-npm-packages-compromised-mini-shai-hulud-supply-chain-attack) 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.

> What three kinds of action reference run after an attacker moves a tag

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](https://socket.dev/glossary/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](https://docs.github.com/actions/concepts/security/workflow-dependency-lockfiles), which is the check that defeats [repojacking](https://socket.dev/glossary/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](https://docs.github.com/en/actions/reference/security/secure-use). 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](https://github.blog/changelog/2025-08-15-github-actions-policy-now-supports-blocking-and-sha-pinning-actions/), 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](https://socket.dev/glossary/least-privilege-principle) at the job level:

```yaml
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 publish
```

`permissions: {}` at the top makes every job ask for what it needs. GitHub [made the `GITHUB_TOKEN` read-only by default on February 2, 2023](https://github.blog/changelog/2023-02-02-github-actions-updating-the-default-github_token-permissions-to-read-only/), 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](https://socket.dev/glossary/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](https://socket.dev/blog/ghostaction-cloud-credentials), 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](https://socket.dev/glossary/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](https://socket.dev/blog/github-actions-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](https://socket.dev/blog/introducing-github-actions-scanning-support) 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 |

> Taint flow from three untrusted sources to three sinks, with Socket's seven GitHub Actions alerts on the edges

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.
