npm Security Best Practices
A general reference for hardening any npm-ecosystem project (npm, pnpm, yarn, bun) against supply-chain attacks. Adapted from lirantal/npm-security-best-practices. Use this skill whenever you are about to add a dependency, edit install-time configuration, review a lockfile diff, or configure CI install steps.
For the project-specific snapshot date, upstream commit SHA, and quarterly re-read reminder, see references/source.md. For a one-page scan-friendly summary, see references/checklist.md. For ready-to-paste configs, see references/pnpm-config.md and references/ci-snippets.md.
1. Disable post-install scripts
Lifecycle scripts (preinstall, install, postinstall, dependency-side prepare) run during npm install against every transitive dep. Compromised packages exfiltrate secrets or install malware from these hooks. The default-deny stance: block all of them, then allowlist exceptions.
How to apply
npm / pnpm (≤ 9): set ignore-scripts=true in .npmrc. Per-install override: npm install --ignore-scripts.
pnpm 10.x: combine .npmrc::ignore-scripts=true with pnpm-workspace.yaml::onlyBuiltDependencies: [pkg1, pkg2] for an auditable allowlist.
pnpm 11+: the legacy onlyBuiltDependencies list is replaced by an explicit pnpm-workspace.yaml::allowBuilds: map with per-package booleans. Lifecycle scripts that aren't listed default to denied; false is allowed for explicit-deny documentation. Native bindings (better-sqlite3, sharp, etc.) and tools that need git-hook setup (husky) typically go here:
allowBuilds:
husky: true
better-sqlite3: true
esbuild: false # explicit deny, documents the choice
bun: scripts are disabled by default. Allowlist via trustedDependencies in package.json.
yarn (v2+): native bindings work via PnP / Plug'n'Play; for v1 use --ignore-scripts.
Audit-grade alternative: @lavamoat/allow-scripts — independent of the package manager, lists explicitly approved scripts.
2. Block git-based and exotic dependency sources
Transitive deps fetched from git URLs or arbitrary tarball URLs bypass registry signing, provenance, and most static analysis. A legitimate package can ship a transitive dep that resolves to git+https://attacker/foo.git and you've effectively imported attacker-controlled code with no review.
How to apply
npm 11.10.0+: npm config set allow-git none or per-install npm install --allow-git=none.
pnpm 10.26+: pnpm-workspace.yaml::blockExoticSubdeps: true. Refuses git URLs and non-registry tarball URLs for transitive deps. Errors out at install with the offending package named.
yarn / bun: no first-class flag at time of writing. Audit yarn.lock / bun.lock for git+, http://, or off-registry hosts.
3. Install with cooldown
The first 24–72 hours of a new package version is the prime window for compromised publishes to be detected and unpublished. Refusing brand-new versions buys community time without meaningfully delaying patch adoption.
How to apply
npm: .npmrc::min-release-age=3 (days). Per-install: npm install <pkg> --before=2026-01-01.
pnpm 10.26+: pnpm-workspace.yaml::minimumReleaseAge: 4320 (minutes — 3 days). Escape hatch for genuine security patches: pnpm install --no-minimum-release-age.
bun: see Bun's install config.
Default suggestion: 3 days is a defensible middle ground. Shorter (24h) misses weekend incidents; longer (14d+) delays legitimate patches.
4. Harden installs with security tools
4.1 npq
npq wraps npm install and validates against Snyk's CVE DB, package age, typosquatting heuristics, registry signatures, provenance, suspicious scripts, and overall package health.
npm install -g npq
npq install <package> # npm-style
NPQ_PKG_MGR=pnpm npq install <package> # pnpm
Best for manual dep additions — not a substitute for the policy flags above.
4.2 Socket Firewall (sfw)
sfw intercepts ALL installs and blocks packages flagged by Socket's threat intel (malicious scripts, typosquatting, dependency confusion, suspicious network/filesystem access).
npm install -g sfw
sfw npm install
sfw pnpm install
More aggressive than npq. Vendor lock-in to Socket; full features require a Socket account. Best fit for orgs that want install-time defense as a paid service.
5. Prevent lockfile injection
A malicious PR can edit package-lock.json or pnpm-lock.yaml to swap a tarball URL or remove the checksum without changing package.json. lockfile-lint validates the lockfile against a security policy.
How to apply
npm install --save-dev lockfile-lint
npx lockfile-lint \
--path pnpm-lock.yaml \
--type npm \
--allowed-hosts npm \
--validate-https \
--validate-package-names \
--validate-checksum
Wire as a preinstall script AND as a dedicated CI step BEFORE any install. Two enforcement points — preinstall catches local drift, CI catches the malicious PR before merge.
pnpm-workspace.yaml::blockExoticSubdeps: true (practice #2) covers a subset of the same attack class for pnpm specifically; the two are complementary.
6. Use deterministic installs in CI
Lockfiles must be respected to be useful. Tooling that "silently updates the lockfile if it doesn't match package.json" defeats the purpose.
How to apply
| Tool | Command |
|---|---|
| npm | npm ci |
| pnpm | pnpm install --frozen-lockfile |
| yarn | yarn install --immutable --immutable-cache |
| bun | bun install --frozen-lockfile |
| deno | deno install --frozen |
Commit the lockfile to version control. The CI install step SHALL fail when the lockfile disagrees with package.json.
7. Avoid blind dependency upgrades
npm update, npm-check-updates -u, and similar "bump everything" tools mask supply-chain attacks behind a wall of green checks. Use reviewed update flows instead.
How to apply
- Interactive review:
npx npm-check-updates --interactive— opt in to each bump. - Automated with policy: Dependabot / Snyk / Renovate. Configure to open PRs (not auto-merge), batch by minor/major, and require approval.
- Manual one-at-a-time bumps when the dep is sensitive (auth libs, crypto, native bindings).
Coupled with practice #3 (cooldown), this prevents "bumped to 1.2.3 published 5 minutes ago" surprises.
8. Harden npx execution
npx arbitrary-package execution is a remote code execution primitive. Pre-install in a vetted workspace, then force offline execution.
How to apply
# One-time setup: create a vetted workspace
mkdir -p $HOME/mcp && cd $HOME/mcp
npm init -y
npm install <package> --save
# Every invocation: offline, no downloads
npx --include-workspace-root --workspace $HOME/mcp --no --offline <package>
--no refuses to download; --offline rejects any network. Only applicable when you have a small, stable set of npx targets (e.g., MCP servers).
If you don't use npx at all in your project, skip this practice.
9. No plaintext secrets in .env
.env files leak via misconfigured backups, accidental commits, IDE auto-sync to cloud, malware reading process.env. Use secret references that resolve at runtime through a vault.
How to apply
# .env (committable references, NOT plaintext)
DATABASE_PASSWORD=op://vault/prod-db/password
API_KEY=infisical://project/prod/api-key
# Runtime
op run -- npm start # 1Password CLI
op run --env-file=.env -- node server.js
infisical run --env=prod -- npm start
Local development secrets (a token only used against localhost) are a defensible exception. Production secrets are not.
10. Develop in containers
A malicious postinstall script with file-system access to your ~/.ssh/, ~/.aws/, browser profile, and password manager is game over. Isolating dev environments inside containers limits the blast radius.
How to apply
// .devcontainer/devcontainer.json
{
"image": "mcr.microsoft.com/devcontainers/typescript-node:22-bookworm",
"postCreateCommand": "corepack enable && pnpm install",
"remoteUser": "node",
"runArgs": ["--security-opt=no-new-privileges:true"],
"forwardPorts": [3000],
}
For stronger isolation, combine with capability drops and a read-only root filesystem. Container ≠ sandbox; assume escape is possible and don't put production secrets inside.
11. Enable 2FA on npm publisher accounts
Targeted phishing of maintainer accounts has been the entry point for several major npm incidents (event-stream, coa, ua-parser-js).
How to apply
npm profile enable-2fa auth-and-writes # required for publishing
# OR
npm profile enable-2fa auth-only # login + profile changes
auth-and-writes is the right choice for any account that publishes. WebAuthn / passkeys preferred over TOTP.
Skip if you don't publish to npm.
12. Publish with provenance attestations
npm provenance creates a cryptographic chain (source commit, CI workflow run, published tarball) so consumers can verify the package matches the repository at the claimed commit.
How to apply
# .github/workflows/publish.yml
permissions:
id-token: write # OIDC token for the attestation
contents: read
steps:
- uses: actions/checkout@v4
- run: npm publish --provenance
Requires npm CLI 9.5.0+ and a supported CI platform (GitHub Actions, GitLab CI, CircleCI).
Skip if you don't publish to npm.
13. Publish with OIDC trusted publishing
Trusted publishing eliminates long-lived npm tokens. The CI workflow exchanges an OIDC identity for a short-lived publish token scoped to the package.
How to apply
- Configure a trusted publisher on
npmjs.comfor your package, pinning the GitHub org + repo + workflow file path. - In the publish workflow:
permissions: id-token: write steps: - run: npm publish # no --auth-token, no NPM_TOKEN secret
A leaked CI token is now non-fungible: it cannot be reused outside the configured workflow.
Skip if you don't publish to npm.
14. Reduce your dependency tree
Each direct dep multiplies the attack surface by its transitive closure. Modern JavaScript runtimes have absorbed most of what historic utility libraries provided.
How to apply
| Instead of | Use |
|---|---|
lodash.uniq |
[...new Set(array)] |
lodash.merge |
{ ...a, ...b } / structured clone |
axios |
fetch() (native in Node 18+, all modern browsers) |
node-fetch |
fetch() |
uuid |
crypto.randomUUID() (Node 19+, browsers) |
date-fns |
Temporal (staged), Intl.DateTimeFormat |
chalk |
ANSI escape strings + a 5-line helper |
commander (single-verb CLI) |
Hand-roll process.argv parsing |
Don't audit-shame existing deps — replacement is a project, not a hygiene step. Apply when ADDING a new dep.
15. Consult the Snyk Security Database
Beyond CVEs: package health, maintenance signals, popularity trends, dependency-vulnerability counts.
How to apply
Before adding a dep, search security.snyk.io. Look at:
- Open CVEs / unpatched vulnerabilities
- Last-publish age (dormant projects don't ship fixes)
- Maintainers count (bus-factor ≥ 2 preferred)
- Number of direct dependents (popularity signal)
- Issue/PR responsiveness
Cross-reference with the npm page and the GitHub repo. If signals diverge, treat the lower-trust signal as the truth.
16. Do not trust the npmjs.org registry metadata
The npm website's package page is a curated, incomplete view. The actual installed tarball can contain files not listed in the registry metadata, including build artifacts, .env.example references that leak structure, or surprise binaries.
How to apply
# Before adopting a package: pull and inspect
npm pack <package> # creates <pkg>-<ver>.tgz
tar -tzf <pkg>-<ver>.tgz # list contents
tar -xzf <pkg>-<ver>.tgz # extract for review
Or use npq (practice #4.1) which inspects multiple data sources.
17. Prevent dependency confusion
If you publish a private internal package @yourorg/secret-tool to your private registry, an attacker can publish @yourorg/secret-tool to the public npm registry. Default npm resolution favors the version with the higher version number — attacker wins.
How to apply
# .npmrc
@yourorg:registry=https://npm.yourcompany.com/
//npm.yourcompany.com/:_authToken=${NPM_INTERNAL_TOKEN}
This routes the @yourorg scope exclusively to the private registry, regardless of what exists on npmjs.org. For yarn v2+, the equivalent is npmScopes in .yarnrc.yml.
Practice depends on the package name being scoped (@org/name, not bare-name). Unscoped private packages cannot be protected this way.
Per-package-manager support matrix
| Practice | npm | pnpm | yarn | bun |
|---|---|---|---|---|
| 1 ignore-scripts | .npmrc |
.npmrc + workspace allowlist |
--ignore-scripts / PnP |
default off |
| 2 block exotic | npm config set allow-git none (≥ 11.10) |
blockExoticSubdeps (≥ 10.26) |
manual audit | manual audit |
| 3 cooldown | min-release-age |
minimumReleaseAge (≥ 10.26) |
community plugins | bunfig.toml |
| 5 lockfile-lint | ✅ | ✅ | ✅ | ✅ (use --type bun) |
| 6 deterministic | npm ci |
--frozen-lockfile |
--immutable |
--frozen-lockfile |
| 11 2FA | ✅ | (uses npm registry) | (uses npm registry) | (uses npm registry) |
| 12 provenance | --provenance (≥ 9.5) |
via npm publish |
via npm publish |
via npm publish |
| 13 OIDC | ✅ | (uses npm registry) | (uses npm registry) | (uses npm registry) |
Adoption priority (when starting from zero)
If you can only adopt 5 practices, do these first:
- #6 deterministic installs — zero cost, immediate guarantee
- #1 ignore-scripts + allowlist — blocks the most common attack
- #5 lockfile-lint in CI — catches injection at PR time
- #3 cooldown — buys time for community detection
- #11 2FA on the publisher account — if you publish, this is non-negotiable
The remaining 12 are layered defenses, not prerequisites.