groundy
Developer Tools

How to Lock Down GitHub Actions Without Breaking CI

This guide details hardening GitHub Actions by scoping tokens, pinning actions, and avoiding pull_request_target risks, citing preprint findings and audit tools.

Published 7 references
A chipped forest-green ceramic wheel sits in an ivory crescent cradle, anchored by a copper-colored peg, casting a crisp shadow across a warm ivory background.
On this page12 sections

The fastest way to harden a GitHub Actions setup is not to audit every workflow by hand. It is to fix four things in one pass: give the GITHUB_TOKEN explicit least-privilege permissions, pin every action reference to something immutable, stop letting pull_request_target run fork code in a privileged context, and set the cheap controls (job timeouts, persist-credentials: false) that nobody remembers to write down. That ordering is not arbitrary. An empirical study of workflow compliance (arXiv:2507.18062, a preprint whose findings remain unreplicated) reports that “the majority of non-compliance in GitHub Actions arises from the systematic omission of a few key fields, specifically SEC-02 (permissions scoping), REL-01 (job timeouts), and VER-01 (mutable runner labels), rather than active misuse.” Most exposure is blank fields, not bad intent, so a short checklist applied uniformly covers the bulk of it.

What has changed in 2026 is the pinning advice. Hugo’s “Lock your GitHub Actions” argues that GitHub Actions now supports workflow lockfiles, and that the old “pin to a full SHA” recommendation has a successor. The Hacker News discussion of that post then spent a long thread documenting where the new lockfile mechanism stops. This article reconciles both: a setting-by-setting matrix that maps each control to the attack it actually blocks and the behavior it can break, so you can apply it in one pass instead of discovering breakage during a release.

Two caveats up front. First, the academic findings cited here are preprint-reported and unreplicated; treat their measurements as directional. Second, the exact default values below are anchored in preprints and audit-tool READMEs rather than GitHub’s own documentation; the closing section lists which claims to verify against docs.github.com.

The default posture: what an unset GITHUB_TOKEN can actually do

The strongest argument that the common posture is too open comes from the Granite paper (arXiv:2512.11602): “Without these explicit permission declarations, an attacker (via the compromised action) can gain write access to all the fourteen GitHub Actions scopes, including contents, pull-requests, and the Git version history.”

That is the cost of omitting one permissions: block. Any action your workflow uses runs with whatever the token can do, and if you never declared permissions, the token can do a lot. Exactly how much depends on the repository: per the actions-audit documentation, “A repository’s default GITHUB_TOKEN permission set is configurable, and the default was changed for repositories created after a certain date; older repositories can retain a broader default.” Check your repository or organization default before assuming the fourteen-scope worst case applies to you. The fix is to declare permissions: contents: read at the workflow level and add specific scopes only to the jobs that need them, such as pull-requests: write for a job that labels or comments on PRs.

There is a ceiling to what this buys you, and the same paper names it: “Permissions are specified either directly within the workflow configuration file or through the GitHub web interface, but they are only enforced at the granularity of workflows or jobs. Consequently, all steps within a job are granted the same permissions, regardless of their actual functional requirements.” If one step in a job needs to push and another step runs third-party code, both steps share the push permission. The practical consequence is that splitting privileged steps into their own jobs is part of the permissions fix, not an optional refinement. The Granite authors propose runtime per-step enforcement, which tells you the static settings alone do not reach true least privilege.

What this breaks: anything in your workflows that silently relied on default write access. Comment-on-PR actions, release jobs that push tags, and automation that updates the wiki will fail with permission errors until you grant the scope explicitly. Those failures are loud and safe, which is why permissions go first in the rollout order: you find out quickly, and nothing destructive happens when it breaks.

The hardening matrix

SettingRecommended postureAttack it blocksWhat it can break
Workflow/job permissions:Explicit least privilege, contents: read as the baseCompromised action writing to all fourteen scopes where the repository default is still permissive (Granite paper)Jobs that assumed write access: PR comments, tag pushes, wiki updates
Action referencesFull 40-character SHA pins; workflow lockfile as the tracking layerTag poisoning (tj-actions 2025, xygeni-action March 2026)Raw SHA pins leave the readable version only in a trailing comment, and updates depend on your tooling; under the lockfile, Renovate and Dependabot update it automatically
pull_request_targetEliminate fork-code checkout under it; prefer pull_requestFork-PR privilege escalation (Azure Karpenter pattern)Workflows that need secrets or write tokens for fork PRs lose them, by design
timeout-minutesSet on every jobRunaway or hijacked jobs running to the 360-minute defaultGenuinely long builds need tuned values
persist-credentials: false on actions/checkoutSet unless a later step pushesToken lingering in local git config for later steps to exfiltrateSteps that push using the checkout credential need explicit auth

Two rows deserve more than a cell, because the evidence around them shifted this year: pinning and pull_request_target.

Pinning: tags, SHAs, and the new lockfile

The reason pinning exists is that GitHub Actions are fetched from Git, and Git tags are movable pointers. The 2025 tj-actions/changed-files tag swap exposed more than 15,000 repositories, per the workflow-pin-audit tool’s documentation (estimates differ across reports; the Granite paper puts the figure at over 23,000), which adds that “2026 has kept the pattern going. Pinning to a full 40 character commit SHA closes that path, and it is a mechanical fix once you know which lines to change.” The 2026 continuation is documented in the scanner survey (arXiv:2601.14455, preprint-reported): the official xygeni-action “was compromised through tag poisoning, causing repositories that referenced @v5 to execute a C2 reverse shell on their runners.” Anyone who referenced the mutable @v5 tag ran attacker code on their next run. Anyone pinned to a full SHA did not.

Against that backdrop, Hugo’s post documents the guidance change: “GitHub Actions now supports workflow lockfiles. Because GitHub Actions are fetched from Git, the previous advice was to pin to a full SHA instead of a tag. This is because tags are mutable and susceptible to supply-chain attacks where an attacker points a previously safe tag at malicious code.” The gh actions-lock extension, installed with gh extension install github/gh-actions-lock and described in the HN thread as “a new first-party feature by Github”, generates a machine-readable lockfile at .github/workflows/actions.lock recording each action’s ref and resolved commit SHA, and the security linter zizmor added lockfile support in v1.32.0 (versions before that tell you to hash-pin). The lockfile’s appeal is legibility: your workflow files can stay readable (actions/checkout@v7.0.1) while the immutable binding lives in a machine-managed file, instead of burying SHAs in every uses: line.

But the HN thread is where the “without breaking CI” half of this article comes from, because the lockfile does not cover everything the old advice did.

Where lockfiles stop, and why pins still matter

Per the HN discussion, reported by commenters working with the tooling rather than by GitHub itself, the current coverage has three gaps:

  • Reusable workflows are not locked. “They only lock normal action calls. If your workflows invoke other reusable workflows, you need to keep those hash-pinned manually for now.”
  • Composite actions are locked only transitively. Callers using lockfiles are safe, but “the current gh actions-lock default unsafe (since it removes hash-pins from the composite action itself, meaning that any external caller that invokes the action loses their transitive pinning).” If you publish a composite action that others consume, running gh actions-lock with defaults can strip the pins your consumers were relying on.
  • Docker-image uses: clauses are not locked at all.

The practical reading: lockfiles are a tracking and linting layer for normal action calls, not a replacement for immutable references everywhere. If you switch wholesale from SHA pins to lockfiles today, you can end up less pinned than you were, particularly if you maintain composite actions for external consumers or route work through reusable workflows. My recommendation, given the extension is young (the post’s own verify job pins it at v0.4.1) and the gap list comes from a single (if technically detailed) comment thread: adopt the lockfile and zizmor v1.32.0 as your new pinning default for normal action calls, keep full-SHA pins on reusable workflow calls and Docker refs, and if you publish composite actions, verify the generated output before committing rather than trusting the default. Re-check the coverage list against the gh-actions-lock docs before each rollout.

pull_request_target: the escalation pattern to eliminate

This is the setting where the “what it breaks” column is actually the feature. Per the actions-audit tool documentation, “pull_request_target runs in the context of the base repository, so it receives a read/write GITHUB_TOKEN and access to secrets, including for pull requests from forks — which is why combining it with checkout of PR-controlled code is the documented privilege-escalation footgun.”

The same documentation describes the safer default: “Workflows triggered by pull_request from a fork do not receive repository secrets, and their GITHUB_TOKEN is read-only.” So pull_request already implements the lockdown for fork contributions. The problem is that maintainers reach for pull_request_target because they need something the restricted trigger cannot do: post a comment, apply a label, run checks that need secrets. Used that way, with the workflow operating only on PR metadata, it is defensible. The failure mode is checking out the PR’s code and executing it, building it, or running its tests inside that privileged context.

That exact pattern has a documented cost. The scanner survey (arXiv:2601.14455) describes the August 2024 Azure Karpenter Provider incident: a pull-request-triggered workflow chain let a user-supplied ref from an untrusted fork be checked out and executed in a privileged workflow context, leading to exfiltration of privileged CI credentials. The mitigation is structural, not vigilance-based. If a workflow needs secrets or write access, it must not execute contributor-controlled code; if it needs to run contributor code, use pull_request and accept the read-only, secret-free environment. Where you genuinely need both, the standard shape is two workflows: an unprivileged one that runs the code and produces artifacts, and a privileged one that consumes only the artifacts or metadata.

What this breaks, deliberately: fork PRs stop triggering anything that touches secrets. If your CI currently runs integration tests against real credentials for external contributions, that stops working, and none of the sources here document a sanctioned replacement path for those tests. What they do establish is the boundary: whatever replaces them cannot be a workaround that reintroduces the checkout.

Cheap wins: timeouts and credential persistence

Two settings cost almost nothing and close residual risk after the big three.

A job with no timeout-minutes runs up to GitHub’s default of 360 minutes for GitHub-hosted runners, per the actions-audit documentation. A hijacked or hung job holding a runner for six hours is a cost and an exposure window. Setting an explicit timeout on every job is also one of the three fields the compliance study flags as most commonly omitted. The only breakage risk is setting it under your real build duration, so measure your longest job first.

The second is actions/checkout’s credential behavior: per the same audit documentation, the token persists in the local git configuration unless you set persist-credentials: false, and that input defaults to true. Any later step in the job, including third-party actions, can read that credential out of the git config. Setting it to false is safe for the common case; it breaks only jobs where a later step pushes using the checkout-provided credential, which should be re-authenticated explicitly anyway.

What the postmortems tell you about ordering

Lining the incidents up against the matrix shows why the rollout order is defaults first, then pins, then triggers:

  • tj-actions/changed-files, 2025 (workflow-pin-audit): a tag swap exposed more than 15,000 repositories. Blocked by SHA pinning or lockfiles.
  • xygeni-action, March 2026 (arXiv:2601.14455): tag poisoning on @v5 ran a C2 reverse shell on runners. Same control.
  • Azure Karpenter Provider, August 2024 (arXiv:2601.14455): fork ref executed in a privileged context, credentials exfiltrated. Blocked by pull_request_target handling, and its blast radius would have been smaller with least-privilege permissions.

Permissions first, because it bounds what any other failure can do and fails loudly. Pinning second, because mutable tags are the attack path with two documented mass-compromise events. Trigger handling third, because it requires the most judgment per workflow. Timeouts and credential persistence last, because they are safe to apply everywhere at once.

What still needs a docs check before you roll this out org-wide

Three claims above rest on sources other than GitHub’s own documentation, and each deserves a check before you rely on it. The default-permission behavior is anchored in an academic paper’s finding about the fourteen scopes; the 360-minute timeout and persist-credentials default come from an audit tool’s README; the lockfile coverage gaps come from one HN thread. The actions-audit README flags its own limits here: its GitHub-behavior claims “were not re-verified against GitHub’s documentation”, and it directs readers to confirm them against docs.github.com before relying on them in a compliance context. All are plausible and mutually consistent, but exact current default values, the organization-level default-permission setting, and the lockfile feature’s precise scope should be verified against docs.github.com before you encode this matrix as an org policy. That verification step follows from the uncertainty; it is not optional homework.

Whether or not AI-drafted pull requests increase unreviewed workflow edits, none of the sources here measure that. What the evidence does support is narrower and still sufficient: the controls that matter live at the repo and workflow level, they are mostly blank fields today, and the documented incidents trace to workflow-level configuration and mutable references. Fixing the defaults is the part that scales.

Treat the breakage column as expected behavior to verify in your own repos, not as tested outcomes; no source measures how many workflows break when permissions are tightened. Run the changes on a low-stakes repository first, watch a week of CI, then roll the matrix across the organization in one pass.

Frequently Asked Questions

What is the default timeout for GitHub-hosted runners if not specified?

A job with no timeout-minutes runs up to GitHub’s default of 360 minutes for GitHub-hosted runners, per the actions-audit documentation.

Does the new workflow lockfile cover reusable workflows?

Reusable workflows are not locked. “They only lock normal action calls. If your workflows invoke other reusable workflows, you need to keep those hash-pinned manually for now.”

What is the safer alternative to pull_request_target for fork contributions?

The same documentation describes the safer default: “Workflows triggered by pull_request from a fork do not receive repository secrets, and their GITHUB_TOKEN is read-only.” So pull_request already implements the lockdown for fork contributions.

References

Follow the links in the article for context. The supporting material is collected here for further reading.

  1. arXiv:2507.18062arxiv.orgAccessed
  2. Lock your GitHub Actionshugovk.devAccessed
  3. Hacker News discussionnews.ycombinator.comAccessed
  4. arXiv:2512.11602arxiv.orgAccessed
  5. actions-audit documentationgithub.comAccessed
  6. workflow-pin-audit tool's documentationgithub.comAccessed
  7. arXiv:2601.14455arxiv.orgAccessed

Join the discussion

Share a useful perspective or ask a question about this article.

Discussion guidelinesComments privacy