Back to glossary

package.json

package.json is the JSON manifest of every npm package and Node.js project, declaring its name, dependency version ranges, and install-time scripts.

Also known as package.json file, npm package manifest, package manifest, Node.js package.json, npm package.json

package.json (also called the npm package manifest) is the JSON file at the root of every Node.js project and npm package. It names the package, declares version ranges for its dependencies, and lists scripts npm runs at lifecycle points. npm resolves those ranges at install time, so package.json decides which code reaches your machine and what runs during installation.

Most of a package.json is a request, not a record. "axios": "^1.14.0" does not name any code. It tells npm to fetch the newest compatible release on the day the install runs. On March 31, 2026, the newest compatible release was a malicious axios 1.14.1. The same file then decides what executes: every dependency's package.json can name an install script, and before npm v12 those scripts ran by default.

We think of package.json as two different files that share a name. Your root package.json is policy. Since npm v12 shipped on July 8, 2026, it holds the allowlist of dependencies that may run install scripts. A dependency's package.json is untrusted input: the publisher's claim about what the package needs and what should run. Review the first like code. Verify the second against what the tarball actually contains.

What is package.json?

#

package.json is the manifest npm reads to identify, resolve, build, and publish a JavaScript package. npm init creates one, and every published npm tarball carries one at package/package.json. This is a small application's root package.json, built with npm 12.2.1 on October 30, 2026:

JSON
{
  "name": "billing-api",
  "version": "1.4.0",
  "private": true,
  "type": "module",
  "scripts": {
    "start": "node server.js",
    "test": "node --test"
  },
  "dependencies": {
    "axios": "^1.21.0",
    "esbuild": "^0.25.0"
  },
  "devDependencies": {
    "typescript": "~7.0.2"
  },
  "overrides": {
    "follow-redirects": "^1.16.2"
  },
  "allowScripts": {
    "esbuild@0.25.0": true
  }
}

npm reads a package.json from every package in the tree, not just yours. Some fields describe the package to its consumers. Others are instructions npm only accepts from the root project. That split is the most useful thing to know about the file:

FieldWhat npm does with itHonored inCan run code at install?
name, versionIdentity on the registry; a published name@version is immutableEvery packageNo
dependencies, optionalDependencies, peerDependenciesRanges npm resolves against the registry, a Git host, or a URLEvery packageIndirectly: they choose which code arrives
devDependenciesInstalled for the root project onlyRoot onlyIndirectly
scripts (preinstall, install, postinstall)Lifecycle hooks npm runs during installEvery packageYes
binding.gyp file with no install or preinstall scriptnpm treats the package as if it declared install: node-gyp rebuildEvery packageYes
binLinks executables into node_modules/.binEvery packageOnly when invoked
overridesForces a version or replacement anywhere in the treeRoot onlyNo
packageExtensionsAdds missing dependency edges to third-party manifestsRoot only; npm refuses to publish it in a non-private packageNo
allowScriptsAllowlist of dependencies permitted to run install scripts (npm 12+)Root onlyGates it

What does ^ mean in package.json?

#

In package.json, ^ means "compatible with": npm may install any later version that keeps the leftmost non-zero number the same. ^1.2.3 accepts 1.9.0 but not 2.0.0. The rule shifts for versions below 1.0.0, where the leftmost non-zero number is the minor or the patch. We expanded each range with the node-semver library bundled in npm 12.2.1:

Range in package.jsonExpands toAcceptsRejects
^1.2.3>=1.2.3 <2.0.0-01.2.4, 1.9.02.0.0
^0.2.3>=0.2.3 <0.3.0-00.2.90.3.0
^0.0.3>=0.0.3 <0.0.4-00.0.3 only0.0.4
~1.2.3>=1.2.3 <1.3.0-01.2.91.3.0
1.2.x>=1.2.0 <1.3.0-01.2.0, 1.2.91.3.0
1.2.31.2.31.2.3 onlyeverything else
*any version (empty range)everythingnothing

Caret ranges skip prereleases from other version tuples. ^1.2.3 rejects 1.3.0-beta.1 and 2.0.0-beta.1. Tilde (~) is the narrower operator: it allows patch releases only. npm install <pkg> writes a caret range by default, because the save-prefix setting defaults to ^.

Semantic versioning is the promise that makes ^ safe for compatibility. It is not a promise about intent. A caret range is a standing instruction to accept the next release from whoever controls the publish token. In the axios compromise, the registry's own time field shows axios 1.14.1 published at 00:21:58 UTC on March 31, 2026, and 0.30.4 at 01:00:57 UTC. Both added a dependency on plain-crypto-js@4.2.1, whose postinstall script downloaded a remote access trojan. Any ^1.14.0 or ^0.30.0 range resolved to them on the next fresh install.

Most exposure came through transitive dependencies, not direct ones. Our follow-up on the hidden blast radius of the axios compromise found axios: "^1.13.5" declared by @slack/web-api 7.15.0, twilio 5.13.1, and wait-on, among many others. Ranges in published libraries are a deliberate trade-off, not negligence. They let npm deduplicate one shared version instead of installing dozens. The defense belongs in the consuming project:

ApproachWhat npm install does next weekUse it forWhat it does not cover
Caret range, no lockfileTakes the newest compatible releaseNothing you deployEverything
Caret range plus committed package-lock.json, installed with npm ciInstalls exactly the locked treeApplications and servicesNew dependencies, npx runs, global installs
Exact version in package.jsonPins your direct dependency onlyCLI tools you run in CIThat package's own ranges, which still float
Caret range in a published libraryLets each consumer resolve and deduplicateLibrariesConsumer safety, since npm never ships a dependency's lockfile

Verdict: libraries should keep ranges, and applications should keep ranges plus a committed lockfile installed with npm ci. Tools you run in CI belong in a pinned root project, not in a bare npx call. npx foo@1.2.3 pins foo and resolves every dependency of foo fresh against the registry.

Which package.json fields run code during npm install?

#

Three package.json paths run code during npm install: a dependency's preinstall, install, and postinstall scripts; an install script npm synthesizes when the package ships a binding.gyp file; and the prepare script of a Git, file, or link dependency. None of them need you to import the package.

PathWhat triggers itnpm 10 and 11 defaultnpm 12 defaultSeen in
Lifecycle scriptpreinstall, install, or postinstall in a dependency's package.jsonRunsSkipped with a warning unless listed in root allowScriptsplain-crypto-js 4.2.1 in the axios compromise
Implicit node-gyp rebuildA binding.gyp file and no install or preinstall scriptRunsSkipped with a warning unless listed in root allowScriptsMiasma "Phantom Gyp" waves
prepare on a Git, file, or link dependencynpm builds the dependency's source before packing itRunsGit source refused unless allow-git permits it; prepare then needs allowScriptsTanStack compromise

All three paths pass through one gate in npm 12: the allowScripts policy in the root package.json, which no dependency can set for itself. Unapproved scripts are skipped and the install succeeds with a warning, unless strict-allow-scripts turns that into a failure. On npm 10 and 11 the only switch is --ignore-scripts, which is off by default and blocks every script at once.

Install scripts in the scripts field are the path most scanners watch. The second path is the reason watching scripts is not enough. npm's package.json documentation says that if a package has "a binding.gyp file in the root of your package" and no install or preinstall script, npm defaults the install command to node-gyp rebuild. gyp files support command expansion: <!(...) runs a shell command while the build is configured. We built a package whose package.json has no scripts field at all:

JSON
{ "name": "quiet-addon", "version": "1.0.0", "main": "index.js" }
JSON
{
  "variables": { "x": "<!(id -un > /tmp/gyp-ran.txt && echo ok)" },
  "targets": [ { "target_name": "noop", "type": "none" } ]
}

Installed from its tarball with npm 10.9.9, the newest npm 10 release, the command ran:

Bash
$ npm -v
10.9.9
$ npm install
added 1 package, and audited 2 packages in 663ms
$ cat /tmp/gyp-ran.txt
root

The same install under npm 12.2.1 names the synthesized script and skips it:

Bash
$ npm -v
12.2.1
$ npm install
npm warn install-scripts 1 package had install scripts blocked because they are not covered by allowScripts:
npm warn install-scripts   quiet-addon@1.0.0 (install: node-gyp rebuild)
$ cat /tmp/gyp-ran.txt
cat: /tmp/gyp-ran.txt: No such file or directory

Attackers adopted this path before npm closed it. The Miasma campaign's "Phantom Gyp" technique added a binding.gyp to LeoPlatform and RStreams packages on June 24, 2026, using command expansion to launch the payload in index.js. Two days later, the ImmobiliareLabs wave shipped a byte-identical binding.gyp across 22 compromised package versions. The third path is how the TanStack compromise ran on May 11, 2026. Each malicious version added one Git dependency to package.json, "@tanstack/setup": "github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c", pointing at a parentless commit whose prepare script ran bun run tanstack_runner.js.

How does npm 12 store install-script policy in package.json?

#

npm 12 stores install-script approvals in an allowScripts field in the root package.json. Dependency install scripts are blocked by default, and approving one writes an entry you commit with the code. GitHub's npm v12 changelog says dependency lifecycle scripts "and implicit node-gyp builds no longer run unless explicitly allowed." We installed esbuild 0.25.0, which has a postinstall, then approved it:

Bash
$ npm install
npm warn install-scripts 1 package had install scripts blocked because they are not covered by allowScripts:
npm warn install-scripts   esbuild@0.25.0 (postinstall: node install.js)
$ npm approve-scripts esbuild
Approved esbuild:
  added esbuild@0.25.0
JSON
"allowScripts": {
  "esbuild@0.25.0": true
}

By default, npm approve-scripts pins each approval to the installed version. When we upgraded an approved package to a new version, npm 12 blocked its script again until we re-approved it. An entry set to false denies a package until you remove it, and approve-scripts will not overwrite it. strict-allow-scripts=true turns the warning into a failed install, which is what CI should use. Passing --allow-scripts on the command line inside a project fails with EALLOWSCRIPTS, so nobody adds a per-package exception from a one-off command. --dangerously-allow-all-scripts still bypasses the whole policy, and --ignore-scripts still overrides it.

Only the root project's policy counts, whether it sits in package.json or .npmrc. We packed a test package that declared "allowScripts": { "child-pkg": true } for its own dependency. Installed into another project, npm 12.2.1 ignored it and blocked child-pkg's postinstall. A dependency cannot approve its own scripts.

This is the correction we have argued for: default-on install scripts were a design mistake, and pnpm 10 and npm 12 have now reversed it. The gate has limits:

  • An approval covers a command string, not behavior. npm approve-scripts --allow-scripts-pending shows node install.js, not what install.js reads or where it connects.
  • --no-allow-scripts-pin writes name-only entries that approve every future version, including the one a compromised maintainer publishes next.
  • A skipped script does not fail the install. Native modules such as sharp or better-sqlite3 then fail at runtime when the binary they expected was never built.
  • Code in index.js still runs when your application imports the package. Install-time blocking removes one trigger, not the payload.
  • In our tests, strict-allow-scripts failed the install for registry dependencies but only warned for a local file: tarball dependency.
  • npm 10 and npm 11 still run every dependency script by default. The Node.js 22.23.4 image we tested ships npm 10.9.9.

Fields npm honors only in the root package.json

#

overrides, packageExtensions, allowScripts, and the lockfile are root-only for the same reason: they are decisions about your tree, and a dependency should not make them for you. npm's documentation says overrides "are only considered in the root package.json file for a project." Lockfiles follow the same rule. npm never publishes package-lock.json inside a tarball, and npm 12 now also ignores an npm-shrinkwrap.json that a dependency ships, which was the one way a library could lock its subtree for consumers without bundling it. The npm 12 documentation points those publishers to bundleDependencies instead.

overrides is the field to reach for when a vulnerable transitive dependency has a fixed release but its parent has not upgraded. The first entry below raises follow-redirects everywhere in the tree. The second pins form-data only where axios depends on it:

JSON
{
  "overrides": {
    "follow-redirects": "^1.16.2",
    "axios": {
      "form-data": "4.0.6"
    }
  }
}

Overrides force a combination of versions that nobody upstream tested, so run your tests after adding one. They have also been less reliable than they look. The npm overrides bug made overrides silently revert after their first use for nearly three years, until npm 11.2.0. Override values also accept github: specs, which bring every risk of a Git dependency into the root of your tree. packageExtensions is narrower: it can only add missing dependency edges, and npm refuses to publish a non-private package that contains it.

The registry's copy of package.json is not the one on disk

#

Every published version has two copies of package.json, and npm resolves the dependency tree from the registry's copy before it downloads any tarball. At publish time, the client uploads the tarball and a separate JSON manifest. The registry stores that manifest in the package's metadata document and adds fields of its own. For esbuild 0.25.0, the registry's copy has _npmUser, maintainers, gitHead, and a dist object with the tarball's sha512 integrity. The tarball's package.json has none of those.

The two copies can differ even for an honest package. iconv 3.0.1 ships a binding.gyp, and its tarball's package.json declares only a test script. The registry's copy, published with npm 7.10.0 on October 1, 2021, includes the implied build script:

Bash
$ tar -xOzf iconv-3.0.1.tgz package/package.json | jq -c '{scripts, gypfile}'
{"scripts":{"test":"node test/run-tests.js"},"gypfile":null}

$ npm view iconv@3.0.1 scripts gypfile --json
{
  "scripts": {
    "test": "node test/run-tests.js",
    "install": "node-gyp rebuild"
  },
  "gypfile": true
}

A tool that reads only the tarball's package.json sees no install script here. A tool that reads only the registry sees one.

When the two copies disagree on purpose, that is manifest confusion: a dependency or install script sits in the tarball's package.json while the registry metadata, and every tool that reads only the metadata, shows a clean package. Our 2023 write-up on manifest confusion explains why Socket treats the package.json inside the tarball as authoritative and raises a Manifest confusion alert when the two disagree.

The copy on disk after installation is the least trustworthy of the three. plain-crypto-js 4.2.1 ended its postinstall by deleting its own package.json and renaming a clean package.md into its place. We reproduced that trick with npm 10.9.9 and a package we called self-erasing:

Bash
$ cat node_modules/self-erasing/package.json
{ "name": "self-erasing", "version": "4.2.0" }

$ jq '.packages["node_modules/self-erasing"]' package-lock.json
{
  "version": "4.2.1",
  "resolved": "file:../evil/self-erasing-4.2.1.tgz",
  "integrity": "sha512-LEIpq93DxzfnuxhRYjxvTSB3kzvFqX+VWckqilCKcoUTYpfNyrubpx96cYUnxUGp7rnYf6ql5E3uG4+MoGKiIQ==",
  "hasInstallScript": true
}

After the script ran, the package.json in node_modules claimed version 4.2.0 with no scripts. The lockfile still recorded 4.2.1 and hasInstallScript: true, and npm ls reported 4.2.1. During incident response, read the lockfile and registry timestamps, not the package.json files in node_modules. The reverse inference does not hold. A clean lockfile does not prove a clean history, because an npx run or a global install never touches your project's lockfile.

package.json is a declaration written by whoever published last. It can claim a version it is not, list scripts it will delete, and reach for code the registry never saw. npm 12 made the root package.json a real policy file, which is progress. The open problem is that every approval in that policy is still a decision about a command string. Until approval tooling shows what a script does, the only reliable answer to "what will this package run?" is in the code it ships.