Back to glossary

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.

  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) 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. 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.

TriggerWorkflow file and default checkoutGITHUB_TOKEN and secretsVerdict
pull_request from a forkThe pull request's merge commitRead-only token, no secretsSafe place to build and test outsider code
pull_request_targetDefault branch (since December 8, 2025)Base repository's token and secrets; cache is read-onlyLabeling and triage only. Never install, build, or run pull request code
issue_commentDefault branchBase repository's token and secretsSame rules as pull_request_target: any GitHub user can comment on a public repository
workflow_run after a pull requestDefault branchSecrets and a write-capable token, even if the triggering run had neitherTreat artifacts and outputs from the triggering run as untrusted data
push and scheduleThe pushed commit, or the default branchFull token and secretsTrusted 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:

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 Session
$ 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 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.

ReferenceGuaranteesMissesCost
Tag (@v4)Nothing. Resolved at every job startEvery moved tag: tj-actions, trivy-action, actions-coolNone, and backported fixes arrive automatically
Full commit SHAThe same top-level commit on every runTags inside composite actions; whether the commit is safe; backported fixesAn update bot plus a review on every bump
Lockfile (actions.lock)Locked commits for direct and transitive actions, plus source repository identityWhether the locked commit is safe; dependencies of reusable workflows defined in other repositoriesPublic preview; each workflow is onboarded individually
Allowed-actions policyOrg-wide enforcement of SHA pins and a blocklistCode review of anything it allowsAdmin 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:

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

ControlClosesLeaves open
Build and test outsider code under pull_request; keep pull_request_target for labelingPwn requests like Nx and TanStackissue_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 namesActions that evaluate the value or pass it on as an output
Lockfile or full-SHA pins, enforced by policyMoved tags: tj-actions, trivy-action, actions-coolMalicious code at the locked commit
permissions: {} with per-job grantsWrite-token abuse, as in Nx's trigger of publish.ymlSecrets the job references; cache writes
id-token: write on one job behind a reviewed environmentTheft of long-lived publish tokensCode running inside that job, as in TanStack
cache-mode: none on jobs that hold credentialsCache poisoning, as in Ultralytics and TanStackArtifacts passed between jobs
Workflow execution protectionsUntrusted actors and events starting runsA stolen maintainer account that the rules allow, as in GhostAction
Scanning the actions you referenceMalicious or unsafe action code before you lock itA 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:

AlertUntrusted sourceWhere it lands
ghaContextToSinkWorkflow context, such as an issue title or pull request bodyA dangerous sink such as shell execution
ghaArgToSinkAn action input passed with with:A dangerous sink such as shell execution
ghaEnvToSinkAn environment variableA dangerous sink, exploitable when the variable's value is untrusted
ghaContextToEnvWorkflow contextAn environment variable exported to later steps
ghaContextToOutputWorkflow contextAn action output consumed by later steps or jobs
ghaArgToEnvAn action inputAn environment variable exported to later steps
ghaArgToOutputAn action inputAn 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.