Lesson 1: Understanding package.json and Dependency Versioning
Introduction
The package.json file is the heart of every JavaScript project. It defines your application's metadata, scripts, and — most critically for security — your dependencies. Every third-party package your application relies on is declared here, and the way you specify versions has a direct impact on whether your app pulls in safe code or malicious updates.
In this lesson, we'll break down how npm manages packages, how semantic versioning works, and why seemingly small decisions in your package.json can be the difference between a secure application and a compromised one.
What is package.json?
The package.json file is a JSON manifest that lives at the root of your project. It contains:
- name and version: Your project's identity.
- scripts: Commands you can run with
npm run. - dependencies: Packages required for your app to run in production.
- devDependencies: Packages only needed during development (testing tools, bundlers, linters).
Every entry under dependencies or devDependencies maps a package name to a version range. That range determines exactly which version npm will install — and this is where security begins. Here's a minimal example:
Semantic Versioning (SemVer)
npm follows Semantic Versioning, a convention that gives meaning to version numbers. A version number like 4.18.2 is broken into three parts:
- MAJOR (4): Breaking changes that are not backwards-compatible. Upgrading from 3.x to 4.x may require code changes.
- MINOR (18): New features added in a backwards-compatible way. Your existing code should still work.
- PATCH (2): Bug fixes and security patches. No new features, no breaking changes.
When a library maintainer discovers a security vulnerability, they release a patch version with the fix. For example, the serialize-javascript package had a critical Remote Code Execution vulnerability (CVE-2020-7660) in all versions below 3.1.0. The fix was released as version 3.1.0 — a minor version bump that patched the flaw.
Understanding SemVer is essential because the version range syntax you use in package.json determines whether you automatically receive these security patches — or miss them entirely.
Version Range Syntax
Exact Version
Installs only version 4.17.21. No other version will be pulled, ever. This gives you maximum control but means you won't automatically receive security patches.
When to use: When you need absolute determinism, or when you've audited a specific version and want to guarantee nothing changes.
Tilde (~) — Patch Updates Only
Allows updates to the patch version only. This will install 4.17.20, 4.17.21, 4.17.22, etc., but never 4.18.0.
Think of tilde as: "I trust this maintainer to release safe bug fixes, but I don't want surprise new features."
Resolves to: >=4.17.20 <4.18.0
Caret (^) — Minor + Patch Updates
Allows updates to both minor and patch versions. This will install 4.18.0, 4.18.2, 4.19.0, etc., but never 5.0.0.
Think of caret as: "I trust this maintainer's backwards-compatibility promise within this major version."
Resolves to: >=4.18.0 <5.0.0
The caret (^) is the default when you run npm install <package>.
Other Range Specifiers
| Syntax | Meaning | Example |
|---|---|---|
* |
Any version | Extremely dangerous — never use in production |
>=1.2.0 |
Any version 1.2.0 or higher | No upper bound — risky |
1.2.x |
Any patch of 1.2 | Same as ~1.2.0 |
latest |
Whatever the newest version is | Never use this |
Special Case: Versions Starting with 0.x
For versions below 1.0.0, the caret behaves differently because pre-1.0 packages are considered unstable:
This resolves to >=0.2.3 <0.3.0 — effectively the same as tilde. The caret is more conservative for 0.x versions because even minor bumps may contain breaking changes.
The Security Implications of Version Ranges
"I've had friends telling me that what they do in CI, they literally have a step that's like npm upgrade everything to latest. And then they test stuff. They blindly upgrade on CI, which is scary because in CI you have environment variables, you have stuff. If you installed malware, it has access to your code, to your proprietary npm private modules... There's other more important data that you could also leak that would be useful in attacking you."
— Liran Tal, Director of Developer Advocacy at Snyk (Señors @ Scale podcast)
Here's where versioning meets security. Consider this scenario:
You have "chalk": "^5.3.0" in your package.json. An attacker compromises the chalk maintainer's npm account and publishes version 5.6.1 with malicious code injected. Because you used the caret, the next time anyone runs npm install in your project, they get 5.6.1 — the compromised version.
This is exactly what happened in the September 2025 npm supply chain attack, where 19 popular packages including chalk, debug, and ansi-styles were compromised through a phishing attack against a maintainer. The malicious versions were designed to silently steal cryptocurrency wallet data.
The Tradeoff
Version ranges create a fundamental tension:
- Wider ranges (caret, tilde) → You automatically receive security patches, but you also automatically receive compromised versions if a maintainer's account is hijacked.
- Exact pinning → You're protected from malicious new releases, but you miss critical security patches unless you manually update.
There is no perfect answer. The best practice is to combine strategies, which we'll explore throughout this course.
The Lockfile: package-lock.json
When you run npm install, npm resolves all your version ranges to specific versions and records the results in package-lock.json. This file is a snapshot of your entire dependency tree — every package, every version, every sub-dependency.
Key fields:
- version: The exact resolved version.
- resolved: The URL where npm downloaded it.
- integrity: A cryptographic hash (SHA-512) to verify the package hasn't been tampered with.
Why the Lockfile Matters for Security
The lockfile pins your entire dependency tree. Even if your package.json says "chalk": "^5.3.0", the lockfile ensures that every developer on your team, every CI build, and every deployment gets exactly 5.3.0 — until someone explicitly updates it.
Critical rules:
- Always commit
package-lock.jsonto version control. It's not optional. - Use
npm ciinstead ofnpm installin CI/CD and production.npm cireads the lockfile and installs exactly what it specifies. If the lockfile is out of sync withpackage.json, it fails with an error rather than silently resolving new versions. - Treat lockfile changes as code changes. When you see
package-lock.jsonmodified in a pull request, review it. Unexpected version bumps could indicate a problem.
The Transitive Dependency Problem
Even if you pin your direct dependencies to exact versions, your dependencies have their own dependencies, and those might use ranges. For example:
Your exact pin of axios controls which version of axios you get, but follow-redirects will resolve to whatever the latest 1.x is at install time. The lockfile protects you here — it freezes the entire tree. But if you delete node_modules and package-lock.json and reinstall (a common but dangerous practice), you get a fresh resolution that could pull in compromised transitive dependencies.
Dependency Types: What Goes Where
dependencies
These ship with your application. They run in production. A vulnerability here directly affects your users.
devDependencies
These are only used during development and build. They don't ship to production (or shouldn't). A vulnerability here affects your development environment and CI/CD pipeline — still dangerous, as the Shai-Hulud attack demonstrated by targeting CI/CD workflows to propagate malware.
peerDependencies
These declare that your package is compatible with a certain version of another package, but doesn't install it. The consuming project must provide it. Peer dependency mismatches are a common source of npm audit warnings.
optionalDependencies
Packages that npm will try to install but will continue even if installation fails. Often used for platform-specific native modules.
Practical Exercise: Auditing Your Version Ranges
Open any package.json in a project you're working on and answer these questions:
- How many dependencies use the caret (
^)? Each one will auto-update to the latest minor/patch version. - How many dependencies use exact versions? These are locked but won't receive automatic security patches.
- Do you have a
package-lock.jsoncommitted? If not, every install is a roll of the dice. - When was the last time you reviewed your lockfile diff in a PR?
Try running these commands:
Key Takeaways
- The
package.jsonversion syntax (^,~, exact) directly controls your exposure to both security patches and supply chain attacks. - The caret (
^) is npm's default and allows minor + patch updates — convenient but risky if a maintainer is compromised. - The tilde (
~) is more conservative, allowing only patch updates. - Exact pinning gives maximum control but requires manual updates.
- Always commit your lockfile and use
npm ciin production environments. - Version pinning alone doesn't protect you from transitive dependency attacks — you need the lockfile for that.
- There is no single "right" version strategy; security requires a layered approach combining pinning, lockfiles, auditing, and monitoring.
What's Next
In Lesson 2, we'll explore what happens when things go wrong: real-world npm exploits, from the serialize-javascript vulnerability to the 2025 supply chain attack that compromised packages with billions of weekly downloads. Understanding how attacks work is the first step to defending against them.