Lesson 3: Defending Your Project — npm Security Best Practices
Introduction
In Lesson 2, we saw how attackers exploit the npm ecosystem. Now it's time to fight back. This lesson covers the concrete, actionable practices you should adopt to protect your projects — from lockfile discipline and install-time defenses to private registries and CI/CD hardening.
"The mind shift towards developers as a pivotal part of fixing security and addressing security issues, not just security people involved in the loop. Early in the days, security was not developer-oriented. It was network security, perimeter security, firewalls. These days, think about application security, think about your dependencies, your application's dependencies, the code that you have. I think that's the biggest mind shift in this decade."
— Liran Tal, Director of Developer Advocacy at Snyk (Señors @ Scale podcast)
No single practice is bulletproof. The goal is defense in depth: multiple layers that collectively make it extremely difficult for a compromised package to reach your production environment.
Layer 1: Lockfile Discipline
Your lockfile is your single most important security mechanism against supply chain attacks. Let's get this right.
Always Commit Your Lockfile
The package-lock.json (or yarn.lock, pnpm-lock.yaml) freezes your entire dependency tree to exact versions. Without it, every npm install is a fresh resolution that could pull in compromised packages.
Use npm ci in CI/CD and Production
npm install reads your package.json ranges and may update the lockfile. npm ci reads the lockfile and installs exactly what it specifies. If the lockfile is out of sync with package.json, it fails rather than silently resolving new versions.
Never Delete and Regenerate the Lockfile
A common but dangerous practice is deleting node_modules and package-lock.json to "fix" dependency issues. This throws away your frozen dependency tree and resolves everything from scratch — potentially pulling in compromised versions of transitive dependencies.
If you have genuine dependency conflicts, resolve them surgically rather than nuking the lockfile.
Review Lockfile Diffs in Pull Requests
When package-lock.json changes in a PR, don't just wave it through. Check:
- Are the version changes expected?
- Did any packages jump major versions unexpectedly?
- Are there new dependencies you didn't add?
Layer 2: Version Pinning Strategy
There's no universal "right" approach, but here's a practical framework:
For Direct Dependencies
Use exact versions for critical packages and caret ranges for less critical ones, combined with a committed lockfile:
The "N-1" Rule
A practical guideline from the DevSecOps community: don't use the absolute latest version of anything in production. Use one version behind (N-1). This gives the community time to discover and flag malicious or broken releases before you adopt them.
Cooldown Periods
Several security tools now support enforcing a minimum package age before allowing installation. Packages compromised and then identified typically get removed within hours or days. A cooldown period (even 24 hours) prevents your builds from pulling malicious versions during that critical window.
Some tools that support this:
- Aikido Safe Chain: Offers a 24-hour cooldown option
- JFrog Curation: Can require packages to be published for a minimum number of days
- pnpm and Yarn: Added delay settings after the Shai-Hulud attack
Layer 3: Disable Post-Install Scripts
Malicious packages frequently use preinstall and postinstall scripts to execute code the moment you run npm install — before you've reviewed anything. You can disable these globally.
Option 1: Project-Level .npmrc
Create or edit .npmrc at your project root:
Option 2: Per-Install
The Tradeoff
Some legitimate packages need install scripts to function (e.g., native binary compilation, node-gyp builds). After disabling scripts globally, you can use tools like can-i-ignore-scripts to identify which packages genuinely need them:
This gives you a breakdown of every package with pre/post install scripts, what they do, and whether they're important for functionality. You can then selectively allow scripts for trusted packages.
Verify Scripts Before Allowing
For packages that need scripts, audit what those scripts actually do before enabling them:
Layer 4: npm audit and Beyond
Running npm audit
npm audit checks your installed packages against npm's advisory database for known vulnerabilities:
Limitations of npm audit
npm audit is useful but insufficient on its own:
- It only detects vulnerabilities after they're reported to npm's database
- It misses newly published malicious packages
- It misses recently compromised legitimate packages (like the September 2025 attack)
- It misses abandoned dependencies with unpatched CVEs
- It can produce excessive noise, flagging vulnerabilities in devDependencies that don't affect your production code
Understanding npm audit Output
When npm audit flags a vulnerability, evaluate it in context:
- Is the vulnerable package a direct or transitive dependency? Transitive vulnerabilities may not be exploitable in your usage.
- Is it a production or development dependency? A vulnerability in a build tool may not affect your deployed application.
- Is the vulnerability reachable from your code? A prototype pollution vulnerability in a function you never call is a lower priority.
- Is a fix available? Sometimes the answer is "update to version X" — check if that's compatible with your project.
Layer 5: Private Registries and Proxies
For teams and organizations, a private npm registry acts as a gateway between the public npm registry and your developers.
Why Use a Private Registry?
- Package curation: Approve packages before they're available to developers
- Caching: If a malicious version is published and then removed from npm, your cached clean version is unaffected
- Audit trail: Track what packages are being installed across your organization
- Dependency confusion prevention: Private packages served from your own registry won't collide with public names
Popular Options
- Verdaccio: Free, open-source, lightweight. Perfect for small teams.
- Artifactory (JFrog): Enterprise-grade. JFrog Xray provides deep security scanning, and Curation policies can enforce cooldown periods.
- Nexus (Sonatype): Enterprise-grade with a Repository Firewall that blocks malicious packages proactively.
- GitHub Packages: Integrates well if you're already in the GitHub ecosystem.
Basic Verdaccio Setup
Your Verdaccio instance acts as a proxy: requests for packages are forwarded to the public registry, cached locally, and served to your team. You can configure it to block specific packages or require approval for new ones.
Layer 6: CI/CD Pipeline Hardening
Your CI/CD pipeline is a high-value target. If an attacker can compromise your build process, they can inject malicious code into your releases.
Essential CI/CD Practices
Additional CI/CD Hardening
- Pin actions to commit SHAs, not tags:
actions/checkout@abc123instead ofactions/checkout@v4 - Restrict network access during builds where possible
- Never store npm tokens in plain text; use encrypted secrets
- Fail builds on high-severity vulnerabilities:
npm audit --audit-level=high - Use
--beforeflag with npm: Lock installs to before a known-good date
Layer 7: Account and Token Security
If you publish packages, your account is a potential attack vector for everyone who depends on your packages.
Enable Strong 2FA
Enable two-factor authentication for both login and publishing:
Use hardware security keys (FIDO/WebAuthn) instead of TOTP when possible. The September 2025 attack bypassed TOTP-based 2FA through adversary-in-the-middle phishing.
Manage Tokens Carefully
Use Trusted Publishing
npm supports Trusted Publishing, which generates provenance attestations providing cryptographic proof that a package was built from a specific source repository commit. This eliminates the need for long-lived publish tokens.
Log Out When Not Publishing
Keep an npm logged-out user as your default. Log in only when you need to publish:
Layer 8: Responsible Dependency Management
Minimize Dependencies
The npm ecosystem has a culture of tiny single-purpose packages. While this promotes code reuse, every dependency is a potential attack vector. Sometimes a small amount of your own code is safer than a lot of dependencies.
Before adding a new package, ask:
- Can I implement this functionality in a few lines?
- How many transitive dependencies does this package bring?
- Who maintains it? Is it a single person or a well-funded team?
- When was it last updated?
- How many weekly downloads does it have?
Evaluate Before Installing
Liran Tal, Director of Developer Advocacy at Snyk, built a CLI tool called NPQ that embodies this mindset:
"The idea is you install it, you run it, you can alias npm to NPQ. Before installing the package, NPQ actually checks the package health score. It checks if it has vulnerabilities, when was it last published — maybe like seven hours ago. You probably don't want to install something that was published seven hours ago. Not because of a security issue, maybe there's an actual functional bug. You probably don't want to be the first person to test this."
"Doing it mindfully and consciously upgrading because you need a set of features makes more sense to me rather than just blindly accepting third party software, which you have no idea what's going on there."
— (Señors @ Scale podcast)
You can adopt the same approach manually:
Use npm Package Provenance
npm now supports package provenance, which lets you verify that a package was built from a specific GitHub commit. Look for the provenance badge on npmjs.com:
Quick Reference: Security Checklist
Here's your security checklist, ordered by impact and ease of implementation:
Immediate (do today):
- Commit
package-lock.jsonto version control - Switch CI/CD to use
npm ciinstead ofnpm install - Run
npm auditand address critical/high findings - Enable 2FA on your npm account (auth-and-writes mode)
Short-term (this week):
- Add
ignore-scripts=trueto your.npmrc - Review your version ranges — consider exact pinning for critical packages
- Add
npm auditto your CI/CD pipeline - Review and revoke unnecessary npm tokens
Medium-term (this quarter):
- Set up a private registry proxy (Verdaccio for small teams)
- Implement cooldown periods for new package versions
- Add automated dependency scanning to CI/CD
- Create a policy for evaluating new dependencies before adding them
Ongoing:
- Review lockfile diffs in every PR
- Stay informed about npm security incidents
- Regularly run
npm outdatedand update dependencies deliberately - Rotate npm tokens and review access permissions
Key Takeaways
- Security is layered — no single practice protects you completely.
- The lockfile +
npm ciis your most important defense against supply chain attacks. - Disabling post-install scripts removes a major attack vector at the cost of some convenience.
- Private registries add curation and caching between your team and the public registry.
npm auditis a baseline, not a complete solution.- Account security (2FA, token management) protects the ecosystem if you publish packages.
- The best defense is a culture of deliberate, reviewed dependency management.
What's Next
In Lesson 4, we'll go hands-on with security tools: from npm audit deep-dives to third-party scanners like Snyk, Socket, and deps.dev. You'll learn which tools catch which threats, how to combine them, and how to build automated scanning into your workflow.