
Supply-Chain Guardrails for npm, pnpm, and Yarn

In our next post we walk through three back-to-back npm compromises that exposed just how fragile the open-source supply chain remains. Each attack escalated quickly: an exploit in GitHub Actions, a phished maintainer account, and finally a worm spreading through the registry itself. This post is a practical guide, concrete steps any team can take to mitigate the same attack paths before they reach production.
How to protect your projects
When You Publish Packages
If you maintain and publish packages, your account is a prime target. A single stolen credential can poison every downstream project. GitHub has stated it will change npm’s authentication and publishing options in the near future to raise the bar against account takeovers (see GitHub’s announcement for details). Immediate guardrails:
Enable strong 2FA: Use hardware security keys (FIDO2/WebAuthn) instead of TOTP. Hardware-backed 2FA is far harder to phish or intercept.
Use trusted publishing with OIDC: Replace long-lived API tokens in CI/CD with trusted publishing. This ties package releases to verified build identities and eliminates token management risks.
Deprecate legacy tokens: If you still rely on “classic” npm tokens, rotate them out. Move to granular, short-lived tokens with the least privileges needed.
Enforce 2FA at org/package level: Make 2FA mandatory for all maintainers and contributors who can publish.
Harden Your GitHub Workflows: Avoid insecure triggers like pull_request_target
on untrusted PRs - GitHub warns this may expose secrets and grant unintended write access. Restrict workflow_dispatch
, set minimal permissions: in your workflow YAML, and run publish jobs only on protected branches after review. For more on secure workflow practices, see GitHub’s Secure use reference guide
When You Install Dependencies
Even if you don’t publish, every project pulls in third-party code. These practices reduce the chance of malicious or broken packages slipping into your builds:
Pin exact versions: Don’t use ranges (^
or ~
) in package.json
. Always commit your lockfile (package-lock.json
, pnpm-lock.yaml
, yarn.lock
) to freeze the entire dependency tree, including transitive dependencies.
Reproducible installs: Always use the strict install mode (npm ci
, pnpm install --frozen-lockfile
, yarn install --frozen-lockfile
). These commands install exactly what’s in the lockfile and fail if it drifts from package.json
.
Lifecycle scripts in CI: Treat lifecycle hooks (postinstall
, prepare
, prepublishOnly
, etc.) as untrusted code. With npm and Yarn, disable them by default (npm_config_ignore_scripts=true
, yarn install --ignore-scripts
) and enable them only when required. With pnpm, scripts are ignored automatically in CI; enable them explicitly if needed.
Delay updates: Wait a few days before installing a newly published release. In that time, the community often reports and removes malicious or broken versions, so you avoid becoming an early victim. This doesn’t mean freezing dependencies - use automated update tools to stay current - but skip day-one installs unless the update is a confirmed security fix.
Review lockfile diffs in PRs: Lockfile changes can pull in new transitive dependencies, scripts, or even maintainers. Always review them before merging.
Use read-only scoped tokens in CI/CD: If your project installs private packages, avoid injecting long-lived full-privilege tokens into builds. Prefer identity-based access (for example OIDC) whenever your registry supports it, so tokens are issued on demand and expire quickly. If identities are not an option, generate tokens that are read-only and scoped only to the registry or org you actually need. This way, even if a malicious dependency exfiltrates the token, it can’t be abused to publish, delete, or hijack your packages.
Guard against typosquats: Attackers publish look-alike packages, sometimes even inflating download counts to appear legitimate, that exploit a single missed keystroke (e.g. express-exp
vs expres
). Always verify names against official docs or trusted sources before adding them.
Package Manager Specifics
The rules above apply everywhere, but each package manager has its own commands and config quirks. Here’s how to apply them in npm
, pnpm
, and yarn
:
NPM
-
Use lockfiles with
npm ci
: Commit the automatically generatedpackage-lock.json
and prefernpm ci
overnpm install
.package.json
only declares your direct dependencies - the libraries you explicitly add. Each of those dependencies can pull in dozens of transitive dependencies of their own. The lockfile records the exact versions of the entire tree, both direct and transitive, so every clone of the repo, local or CI/CD, installs the same code.Developers should use
npm ci
to keep local environments consistent (cloning a repo, switching branches, reproducing a bug). CI/CD pipelines should always use it to guarantee reproducible builds and avoid dependency drift. -
Never use npm install in CI/CD: It can update the lockfile and introduce unreviewed dependency changes.
-
Harden with .npmrc: Use
save-exact=true
to pin exact versions automatically. This ensures every dependency added to package.json matches the lockfile and prevents version drift. -
Disable lifecycle scripts in CI: Lifecycle hooks (postinstall, prepare, prepublishOnly, etc.) run automatically when you install a package. Attackers abuse these hooks to run arbitrary code during install. To reduce risk, set the environment variable
NPM_CONFIG_IGNORE_SCRIPTS=true
or by addingignore-scripts=true
to.npmrc
for consistency across environments. If your project legitimately needs a build step (e.g. compiling a native module), enable scripts only in that specific job. This makes scripts opt-in rather than a hidden execution path for every package.
PNPM
-
Use lockfiles with
pnpm install --frozen-lockfile
: This installs exactly what’s in the lockfile and fails if it doesn’t matchpackage.json
. Developers should use it to keep local environments aligned, and CI/CD pipelines should always use it for reproducible builds. -
Harden with .npmrc: pnpm also reads .npmrc, so you can use
save-exact=true
to pin exact versions automatically. This keeps package.json aligned with the lockfile and prevents version drift. -
Lifecycle scripts: Unlike npm, pnpm disables lifecycle scripts (like
postinstall
) by default in CI. That cuts off one of the most abused malware vectors out of the box. -
Delay adoption of new packages: Starting with pnpm
v10.16
you can set a cooldown period for new releases usingminimumReleaseAge
. This enforces a minimum age before a freshly published version becomes eligible for install. A conservative setting is 60 days, which gives the ecosystem time to flag and remove malicious or broken versions.
YARN
-
Use lockfiles with: Yarn doesn’t have an npm ci equivalent, but you can enforce lockfile installs. In Yarn v1 (classic) use
yarn install --frozen-lockfile
. In Yarn v2+ (Berry) useyarn install --immutable
. Both commands fail if the lockfile andpackage.json
don’t match, ensuring reproducible builds in CI/CD and consistent local environments. -
Harden with .yarnrc: In Yarn v1, .npmrc options like
save-exact=true
are respected. In Yarn v2+, use.yarnrc.yml
and setpreferImmutable: true
orenableImmutableInstalls: true
to enforce strict dependency management. -
Lifecycle scripts in CI: Yarn executes lifecycle hooks (
postinstall
,prepare
,prepublishOnly
, etc.) the same way npm does. In CI, disable them by default withyarn install --ignore-scripts
and enable them only in jobs that legitimately require scripts.
The guardrails shared here are simple but effective, rooted in Trust On First Use (TOFU) and the expectation that malicious packages are usually caught within days. They block the most common attack paths, but they are not enough against more advanced threats.
From Trust to Verification and Isolation
Long-term resilience means moving beyond blind trust in maintainers toward verification and isolation-harder problems, but increasingly tractable with change-tracking, AI-assisted analysis, and applying the Principle of Least Privilege (PoLP). In practice, this means constraining each package to the minimum authority it truly needs-whether that’s limiting filesystem access, network permissions, or execution rights. By isolating packages in this way, a single compromised dependency can’t escalate into a full-system breach.
We’ll explore these stronger, forward-looking defenses in future posts.