
Software Supply Chain Security in Node.js and TypeScript: Dependency Hardening and CI/CD Pipelines
How to protect a Node.js project from supply chain attacks: install scripts, typosquatting, lockfiles, automated audits, GitHub Actions, provenance, CSP and SRI.
A mid-sized Node.js project easily has over a thousand packages in node_modules, written by hundreds of maintainers you’ve never met. Every one of them, at npm install time, can run arbitrary code on your machine and on your CI runner — with access to the file system, environment variables, and whatever tokens happen to be lying around. The code you write is only a small part of what ends up in production.
This isn’t a theoretical scenario. In September 2025, the account of a maintainer of packages like chalk and debug — billions of combined weekly downloads — was compromised through a phishing email, and malicious versions sat on the registry for a few hours. A few days later, the Shai-Hulud worm started spreading on its own: an install script stole the victim’s npm and GitHub tokens and used them to publish infected versions of the same maintainer’s other packages. In March of that year it was tj-actions/changed-files, a GitHub Action used by tens of thousands of repositories, modified to dump CI secrets into the logs.
No single measure protects you from everything. But a set of concrete steps drastically reduces both the chance of installing malicious code and the damage it can do if it happens.
Attack vectors at a glance
| Vector | How it works | Main countermeasure |
|---|---|---|
| Install scripts | preinstall/postinstall run code as soon as the package is installed |
Disable scripts, explicit allowlist |
| Typosquatting | Packages with near-identical names (expres, lodahs, cross-env.js) |
Review new dependencies, lockfile |
| Account takeover | The legitimate maintainer is compromised and publishes a malicious version | Delay on new versions, provenance |
| Transitive dependencies | The vulnerability sits three levels down, in a package you never chose | Automated audits, continuous updates |
| CI/CD pipeline | Compromised actions, secrets exposed to untrusted code | SHA pinning, least privilege, isolated secrets |
| Runtime CDN | An external script changes after deploy (the 2024 polyfill.io case) |
Subresource Integrity and CSP |
Let’s go through them one by one, starting with the best cost/benefit ratio.
1. Disable install scripts
The vast majority of packages don’t need to run code on install. The ones that do are few and easy to spot: native binaries (esbuild, sharp, better-sqlite3, @tailwindcss/oxide), a handful of tools that download an executable. For everything else, a postinstall script is useless at best and, at worst, exactly the vector Shai-Hulud used.
With npm, you disable scripts globally in .npmrc:
# .npmrc
ignore-scripts=true
and re-enable them only for the packages that actually need them:
npm ci
npm rebuild esbuild sharp --ignore-scripts=false
⚠ Warning
ignore-scripts=true also disables your own package.json install lifecycle scripts
(prepare, postinstall) — for example the hook that sets up Husky. Commands you run explicitly
with npm run keep working normally.
pnpm goes further: since version 10 it does not run dependency install scripts by default. Packages that need them must be explicitly approved. This is the actual configuration of this blog’s repository:
# pnpm-workspace.yaml
onlyBuiltDependencies:
- "@tailwindcss/oxide"
- esbuild
- sharp
When you add a package that wants to run a script, pnpm flags it and pnpm approve-builds asks you to decide. That’s the right model: denied by default, allowed by choice. Bun follows the same philosophy with trustedDependencies, Yarn Berry with enableScripts: false.
2. Lockfiles and reproducible installs
The lockfile is what guarantees that CI installs exactly the versions you tested, with the same integrity hashes. Two rules:
- Always commit it, and treat changes to it as code: in a pull request, a lockfile that changes hundreds of lines to “add a small utility” deserves a look.
- In CI, use the command that fails when the lockfile doesn’t match
package.json, instead of silently updating it:
npm ci # npm
pnpm install --frozen-lockfile # pnpm (the default when it detects CI)
yarn install --immutable # Yarn Berry
3. Don’t install freshly published versions
Almost every account-takeover attack is detected and pulled from the registry within hours or a few days. If your project never installs a version published less than a few days ago, you effectively sidestep the whole category.
pnpm supports this natively:
# pnpm-workspace.yaml
# Minutes: 4320 = 3 days
minimumReleaseAge: 4320
Yarn has an equivalent option (npmMinimalAgeGate), Bun has minimumReleaseAge in bunfig.toml. With npm you can get a similar effect with --before, which resolves dependencies as if it were a given date. The same logic should apply to update bots — more on that below.
💬 My take
Three days is a good trade-off for almost every project. The cost is getting security patches a
few days late; for critical vulnerabilities you can always update by hand and add an exception for
that package (minimumReleaseAgeExclude in pnpm).
4. Before adding a dependency
The best time to stop a malicious package is before it lands in the lockfile. A few quick checks:
npm view package-name # maintainers, publish date, repository
npm view package-name versions # a single version published yesterday? suspicious
npm view package-name scripts # does it have a postinstall? why?
Red flags: a name very close to a popular package, a missing or mismatched GitHub repository, very few downloads but a README copied from a well-known project, install scripts that download something from a URL. Tools like Socket analyze these signals automatically on pull requests.
And the most effective question of all: do I actually need it? A ten-line function you wrote yourself has no maintainer to compromise.
5. Automated vulnerability audits
npm audit --omit=dev --audit-level=high
pnpm audit --prod --audit-level high
--omit=dev/--prod limits the check to dependencies that ship to production, and --audit-level makes the command fail only above a given severity: without these two filters the audit turns into noise the team learns to ignore.
For a second data source independent of the npm registry, Google’s OSV-Scanner uses the OSV database and reads lockfiles directly:
osv-scanner scan source -r .
And to verify that installed packages are really the ones signed by the registry — plus, where available, the provenance attestations linking them to the repository and build that produced them:
npm audit signatures
6. Automated updates, with a delay
Dependabot and Renovate keep dependencies current, but opening a PR five minutes after a compromised version is published is exactly what you don’t want. Both support a waiting period:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 3
groups:
minor-and-patch:
update-types: ["minor", "patch"]
// renovate.json
{
"extends": ["config:recommended"],
"minimumReleaseAge": "3 days",
"internalChecksFilter": "strict"
}
Grouping minor and patch bumps into a single weekly PR cuts review fatigue — which is the real reason automated updates so often get merged without a second look.
7. Lock down the CI/CD pipeline
CI is the juiciest target: it holds the tokens to publish packages, deploy, and reach the cloud. A reasonable GitHub Actions workflow:
# .github/workflows/ci.yml
name: CI
on:
pull_request:
push:
branches: [main]
# Least privilege by default: no job can write to the repository
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
# Actions pinned to a commit SHA, not a tag (tags can be moved)
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with:
node-version: 24
cache: npm
- run: npm ci --ignore-scripts
- run: npm rebuild esbuild --ignore-scripts=false
- run: npm audit --omit=dev --audit-level=high
- run: npm test
The key points:
permissions: contents: readat the top level. The defaultGITHUB_TOKENmay have write permissions: if a compromised dependency reads it, it can push commits or tamper with releases. Jobs that need to write declare exactly what they need.- SHA-pinned actions. The
tj-actions/changed-filesattack worked because version tags (@v45) were moved to a malicious commit. A full SHA is immutable. Dependabot can update SHAs too, keeping the version comment. persist-credentials: falsekeeps the token from being stored in the workspace’s git config, where any later process — including a dependency’s script — could read it.- No secrets in the install step. Pass secrets as
envonly to the steps that actually use them, never at the job or workflow level. - Beware of
pull_request_target. It runs with the target repository’s permissions and secrets: checking out and running a fork’s PR code in that context hands the keys to anyone who opens a PR.
8. Publish packages safely
If you maintain packages on npm, your publish token is what an attacker wants most. After the 2025 incidents, npm retired the old “classic” tokens and pushes trusted publishing: CI authenticates to the registry via OIDC, with a short-lived credential valid only for that run. No long-lived token to steal.
# .github/workflows/publish.yml
on:
release:
types: [published]
permissions:
contents: read
id-token: write # required for OIDC
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with:
node-version: 24
registry-url: "https://registry.npmjs.org"
- run: npm ci --ignore-scripts
- run: npm run build
- run: npm publish
The link between package and workflow is configured once in the package settings on npmjs.com. With trusted publishing, npm also generates the provenance attestation automatically: anyone installing the package can verify which repository and which build produced it. Add two-factor authentication to the account, and require 2FA for manual publishing.
9. Isolate execution
Even with all of the above, assume that at some point untrusted code will run. The goal becomes limiting what it can see.
Install dependencies in a throwaway container, with no access to your home directory, SSH keys, or the ~/.npmrc holding your tokens:
docker run --rm -v "$PWD":/app -w /app node:24 npm ci --ignore-scripts
For development, a dev container (VS Code, JetBrains) gets the same effect permanently: node_modules lives inside the container, your secrets outside.
At runtime, the Node.js Permission Model lets you restrict what the process can do:
node --permission \
--allow-fs-read=/app \
--allow-fs-write=/app/tmp \
dist/server.js
Without explicit flags, the process can’t write to disk, spawn child processes (--allow-child-process), create workers (--allow-worker), or load native addons.
ℹ️ Note
The Node.js docs are explicit: the Permission Model is a “seat belt” against mistakes and unexpected behavior, not a sandbox against deliberately malicious code. It adds an obstacle; it doesn’t replace container- or OS-level isolation.
10. Protect the browser runtime: SRI and CSP
The supply chain doesn’t end at build time. If your page loads scripts from a CDN, that script’s content can change after deploy without you touching a line. That’s exactly what happened in 2024 with polyfill.io: the domain changed hands and started serving malicious code to hundreds of thousands of sites.
Subresource Integrity ties the script to an exact hash of its content. If the file changes, the browser refuses to run it:
<script
src="https://cdn.jsdelivr.net/npm/[email protected]/dist/lib.min.js"
integrity="sha384-..."
crossorigin="anonymous"
></script>
Generate the hash like this:
curl -s https://cdn.jsdelivr.net/npm/[email protected]/dist/lib.min.js \
| openssl dgst -sha384 -binary | openssl base64 -A
SRI only makes sense with versioned URLs (@3.2.1, not @latest): a file that changes legitimately would break your site.
Content Security Policy completes the picture by restricting where scripts can come from and, above all, where they can send data. A strict connect-src stops a compromised package that made it into your bundle from exfiltrating data to an attacker’s domain:
Content-Security-Policy: script-src 'self' https://cdn.jsdelivr.net; connect-src 'self' https://api.example.com
I covered the setup in depth in Content Security Policy: a practical guide and in the advanced follow-up on nonces, strict-dynamic and Trusted Types.
Summary checklist
- Install scripts disabled by default, explicit allowlist for native packages
- Lockfile committed,
npm ci/--frozen-lockfilein CI - Minimum age for new versions (
minimumReleaseAge, Dependabot/Renovate cooldown) - Every new dependency reviewed before it’s added
-
npm auditand/or OSV-Scanner in CI with a severity threshold -
npm audit signaturesto verify signatures and provenance - GitHub Actions with minimal
permissions, SHA-pinned actions,persist-credentials: false - Secrets passed only to the steps that use them
- Publishing via trusted publishing (OIDC), 2FA on accounts
- Installs in containers or dev containers
- SRI on every external script, CSP with strict
script-srcandconnect-src
Frequently asked questions
❓ Will --ignore-scripts break my project?
Only for packages that genuinely need an install script, usually those with native binaries
(esbuild, sharp, bcrypt, sqlite). You’ll spot them because they fail at startup or build time:
just add them to the allowlist (npm rebuild <package> with npm, onlyBuiltDependencies with
pnpm).
❓ Is npm audit enough?
No. npm audit finds known, already-reported vulnerabilities: it won’t catch a malicious
package published an hour ago, or a typosquat. It’s useful but reactive, and should sit alongside
preventive measures (disabled scripts, a delay on new versions, dependency review).
❓ What is npm package provenance?
A signed attestation linking a published version to the source repository, commit, and CI workflow
that produced it. It lets you verify the package was built from that code rather than uploaded by
hand by someone with a stolen token. Check it with npm audit signatures.
❓ Why pin GitHub Actions by SHA instead of by version?
Because a tag like @v4 can be moved to a different commit by anyone with write access to the
action’s repository — including an attacker. A full commit SHA is immutable: you always run
exactly the code you reviewed.
Conclusion
Supply chain security isn’t solved by a single tool, but by a set of more cautious defaults: third-party code that doesn’t run until you allow it, versions that don’t get in until they have a few days of history, pipelines with no more permissions than they use, credentials that expire on their own. One by one they’re small changes — most are a single line of config. Together, they turn npm install from an act of faith into a controlled operation.
If you work with Node.js, keep an eye on vulnerabilities in the runtime itself too: I wrote about them in the article on the Node.js January 2026 security releases.

Co-Founder & CTO at PAPION. Senior full-stack engineer specializing in React, TypeScript, Node.js, and application security.