# 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

Source: https://socket.dev/glossary/package-json

Last updated: 2026-10-30

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](https://socket.dev/glossary/npm-node-package-manager) 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](https://socket.dev/blog/npm-12) 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:

| Field | What npm does with it | Honored in | Can run code at install? |
| --- | --- | --- | --- |
| name, version | Identity on the registry; a published name@version is immutable | Every package | No |
| dependencies, optionalDependencies, peerDependencies | Ranges npm resolves against the registry, a Git host, or a URL | Every package | Indirectly: they choose which code arrives |
| devDependencies | Installed for the root project only | Root only | Indirectly |
| scripts (preinstall, install, postinstall) | Lifecycle hooks npm runs during install | Every package | Yes |
| binding.gyp file with no install or preinstall script | npm treats the package as if it declared install: node-gyp rebuild | Every package | Yes |
| bin | Links executables into node\_modules/.bin | Every package | Only when invoked |
| overrides | Forces a version or replacement anywhere in the tree | Root only | No |
| packageExtensions | Adds missing dependency edges to third-party manifests | Root only; npm refuses to publish it in a non-private package | No |
| allowScripts | Allowlist of dependencies permitted to run install scripts (npm 12+) | Root only | Gates 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](https://github.com/npm/node-semver) library bundled in npm 12.2.1:

| Range in package.json | Expands to | Accepts | Rejects |
| --- | --- | --- | --- |
| ^1.2.3 | \>=1.2.3 \<2.0.0-0 | 1.2.4, 1.9.0 | 2.0.0 |
| ^0.2.3 | \>=0.2.3 \<0.3.0-0 | 0.2.9 | 0.3.0 |
| ^0.0.3 | \>=0.0.3 \<0.0.4-0 | 0.0.3 only | 0.0.4 |
| ~1.2.3 | \>=1.2.3 \<1.3.0-0 | 1.2.9 | 1.3.0 |
| 1.2.x | \>=1.2.0 \<1.3.0-0 | 1.2.0, 1.2.9 | 1.3.0 |
| 1.2.3 | 1.2.3 | 1.2.3 only | everything else |
| \* | any version (empty range) | everything | nothing |

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](https://socket.dev/glossary/semantic-versioning-semver) 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](https://socket.dev/blog/axios-npm-package-compromised), 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](https://socket.dev/glossary/transitive-dependency), not direct ones. Our follow-up on [the hidden blast radius of the axios compromise](https://socket.dev/blog/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:

| Approach | What npm install does next week | Use it for | What it does not cover |
| --- | --- | --- | --- |
| Caret range, no lockfile | Takes the newest compatible release | Nothing you deploy | Everything |
| Caret range plus committed package-lock.json, installed with npm ci | Installs exactly the locked tree | Applications and services | New dependencies, npx runs, global installs |
| Exact version in package.json | Pins your direct dependency only | CLI tools you run in CI | That package's own ranges, which still float |
| Caret range in a published library | Lets each consumer resolve and deduplicate | Libraries | Consumer 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](https://socket.dev/glossary/npx-npm-package-runner) 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.

> Diagram: three paths in a dependency's package.json run code during npm install. Lifecycle scripts, an implicit node-gyp rebuild triggered by binding.gyp, and the prepare script of a Git dependency. All three pass through the allowScripts policy in the root package.json. npm 10 and 11 run all three by default. npm 12 skips unapproved scripts with a warning, fails the install under strict-allow-scripts, and refuses Git dependencies unless allow-git permits them.

| Path | What triggers it | npm 10 and 11 default | npm 12 default | Seen in |
| --- | --- | --- | --- | --- |
| Lifecycle script | preinstall, install, or postinstall in a dependency's package.json | Runs | Skipped with a warning unless listed in root allowScripts | plain-crypto-js 4.2.1 in the axios compromise |
| Implicit node-gyp rebuild | A binding.gyp file and no install or preinstall script | Runs | Skipped with a warning unless listed in root allowScripts | Miasma "Phantom Gyp" waves |
| prepare on a Git, file, or link dependency | npm builds the dependency's source before packing it | Runs | Git source refused unless allow-git permits it; prepare then needs allowScripts | TanStack 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](https://socket.dev/glossary/postinstall-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](https://docs.npmjs.com/cli/v12/configuring-npm/package-json) 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](https://socket.dev/blog/miasma-mini-shai-hulud-hits-leoplatform-npm-packages-go-ecosystem) 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](https://socket.dev/blog/miasma-mini-shai-hulud-hits-immobiliarelabs-npm-packages) shipped a byte-identical `binding.gyp` across 22 compromised package versions. The third path is how [the TanStack compromise](https://socket.dev/blog/tanstack-npm-packages-compromised-mini-shai-hulud-supply-chain-attack) ran on May 11, 2026. Each malicious version added one [Git dependency](https://socket.dev/glossary/git-dependencies) 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](https://github.blog/changelog/2026-07-08-npm-install-time-security-and-gat-bypass2fa-deprecation/) 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`](https://docs.npmjs.com/cli/v12/commands/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](https://socket.dev/blog/pnpm-10-0-0-blocks-lifecycle-scripts-by-default) 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](https://socket.dev/glossary/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](https://socket.dev/blog/npm-overrides-bug-fixed) 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](https://socket.dev/glossary/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](https://socket.dev/blog/manifest-confusion) explains why Socket treats the package.json inside the tarball as authoritative and raises a [Manifest confusion alert](https://socket.dev/alerts/manifestConfusion) when the two disagree.

The copy on disk after installation is the least trustworthy of the three. [plain-crypto-js](https://socket.dev/blog/axios-npm-package-compromised) 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.
