# Yarn (Package Manager)

> Yarn is a JavaScript package manager for the npm registry. Yarn Berry uses Plug'n'Play and, since 4.14, blocks dependency install scripts in new projects.

Also known as: Yarn Berry, Yarn Classic, Yarn Modern, yarnpkg, Plug'n'Play, PnP, yarn.lock, .yarnrc.yml, enableScripts, npmMinimalAgeGate, Yarn 6, Zero-Installs

Source: https://socket.dev/glossary/yarn-package-manager

Last updated: 2026-11-01

Yarn is a JavaScript [package manager](https://socket.dev/glossary/package-manager) that installs packages from the [npm registry](https://socket.dev/glossary/npm-registry) and records every resolved version in `yarn.lock`. Facebook released it in 2016 as a faster, deterministic alternative to the npm client. Modern Yarn, called Yarn Berry, defaults to Plug'n'Play instead of `node_modules`, and since version 4.14 it blocks dependency install scripts in new projects.

Yarn ships in three lines today. Yarn Classic (1.x) is frozen at 1.22.22, and it is still what `npm install -g yarn` gives you. Yarn Berry (2.x through 4.x) is the maintained line. As of November 2026, its current release is [4.18.1](https://www.npmjs.com/package/@yarnpkg/cli-dist), published September 24, 2026. Yarn 6, a rewrite in Rust, is still in release candidates.

We think Yarn 4.18.1 has the strictest defaults of npm, pnpm, and Yarn for a project created today. It runs no dependency install scripts, fetches no Git dependencies, and enforces a one-day release-age gate that fails an exact pin instead of waving it through. Those defaults apply only to projects Yarn has never seen. When Yarn 4.18.1 meets a lockfile written by Yarn 4.13 or earlier, or by Yarn Classic, it writes the old permissive values into `.yarnrc.yml`. The only message it prints says it migrated the project. An existing Yarn project with a committed lockfile therefore keeps running every dependency's install scripts after it upgrades. We reproduce that below, along with the three lines to delete.

## What is Yarn, and how is it different from npm?

Yarn is an alternative client for the npm registry. It reads the same `package.json`, downloads the same packages, and publishes to the same registry. Facebook [announced Yarn on October 11, 2016](https://engineering.fb.com/2016/10/11/web/yarn-a-new-package-manager-for-javascript/), built with engineers from Exponent, Google, and Tilde. The npm client had no lockfile then. Yarn's `yarn.lock` pinned every resolved version, so two machines installed the same tree. npm added `package-lock.json` the following year.

Speed and determinism no longer separate the two tools. What separates them now is how Yarn Berry lays out dependencies, what its lockfile records, and what it lets a dependency do during install. The rest of this entry takes those in order.

| Line | Latest release | Status on November 1, 2026 |
| --- | --- | --- |
| Yarn Classic (1.x) | 1.22.22, March 9, 2024 | Frozen. Still the latest tag of the yarn package on npm. |
| Yarn Berry (2.x to 4.x) | 4.18.1, September 24, 2026 | Maintained in yarnpkg/berry. Pinned per project. |
| Yarn 6 | 6.0.0-rc.23, October 14, 2026 | Release candidate, rewritten in Rust in yarnpkg/zpm. |

The [Yarn 6 overview](https://v6.yarnpkg.com/concepts/yarn-6.html) said a stable release was expected in the third quarter of 2026. It had not shipped by November 1. The same page reports a warm-cache Next.js install dropping from 577 ms on Yarn 4 to 184 ms on Yarn 6. Yarn 6 also switches `yarn.lock` to JSON.

Berry is usually installed through the `packageManager` field in `package.json`, which Corepack reads to fetch the pinned Yarn version. That path is going away: [Node.js 25 stopped bundling Corepack](https://socket.dev/blog/node-js-tsc-votes-to-stop-distributing-corepack), and Yarn 6 replaces it with a built-in version manager called Yarn Switch. Attackers noticed the gap. In July 2026 we found [a fake Corepack site](https://socket.dev/blog/fake-corepack-site-distributes-infostealer-and-proxyware), corepack\[.\]org, that pushed an infostealer and proxyware to developers looking for the tool.

## How Plug'n'Play replaces node\_modules

Yarn Plug'n'Play (PnP) replaces Node's directory search with a lookup table. Node normally resolves `require('ms')` by walking up the directory tree and checking each `node_modules` folder it finds. npm and Yarn Classic hoist transitive dependencies to the top of that tree, so the search succeeds even for packages you never declared. Yarn Classic 1.22.22 shows it:

```sh
$ yarn add debug@4.4.1
$ node -e "console.log(require('ms/package.json').version)"
2.1.3
```

The project never declared `ms`. It loads because `debug` depends on it and hoisting put it at the top level. An undeclared import like this is a [phantom dependency](https://socket.dev/glossary/phantom-dependencies). It works until `debug` drops `ms`, and it hides real dependencies from review.

Yarn Berry writes no `node_modules` directory by default. It writes `.pnp.cjs`, a generated file listing each package version's exact dependencies and where its files live. The files stay inside zip archives in Yarn's cache, which is shared by every project on the machine by default. `yarn node` and `yarn run` load `.pnp.cjs`, which patches Node's resolver to consult the map. We ran the same install with Yarn 4.18.1 in a project named `app`:

```sh
$ yarn add debug@4.4.1
$ ls -A
.pnp.cjs  .yarn  package.json  yarn.lock
$ grep -A5 '"npm:4.4.1", {' .pnp.cjs | head -6
      ["npm:4.4.1", {\
        "packageLocation": "../../../../../root/.yarn/berry/cache/debug-npm-4.4.1-6eab84b9f7-10c0.zip/node_modules/debug/",\
        "packageDependencies": [\
          ["debug", "npm:4.4.1"]\
        ],\
        "linkType": "SOFT"\
$ yarn node -e "require('ms')"
Error: Your application tried to access ms, but it isn't declared in your dependencies; this makes the require call ambiguous and unsound.

Required package: ms
Required by: /home/user/app/
```

> Figure 1. Yarn Plug'n'Play resolution in a project that depends only on debug@4.4.1 (Yarn 4.18.1).
>
> How Yarn Plug'n'Play resolves require calls through .pnp.cjs

Figure 1 as text. The project `app` declares only `debug@4.4.1`. In `.pnp.cjs`, the entry for `app@workspace:.` lists `debug` mapped to `npm:4.4.1`, and the entry for `debug@npm:4.4.1` lists `ms` mapped to `npm:2.1.3`. There is no entry that lets `app` load `ms`. When the project calls `require('debug')`, or when `debug` calls `require('ms')`, the resolver finds a mapping and Node reads the files directly from `debug-npm-4.4.1-…-10c0.zip` and `ms-npm-2.1.3-…-10c0.zip` in `~/.yarn/berry/cache/`. When the project calls `require('ms')`, the resolver throws the error "Your application tried to access ms, but it isn't declared in your dependencies" without searching the disk.

PnP's strictness has costs. Tools that read `node_modules` directly break, and Yarn's ESM support under PnP relies on Node's experimental loader API; our Yarn 4.18.1 installs printed warning `YN0092` to say so. Setting `nodeLinker: node-modules` builds an npm-style tree and gives up the strictness. Projects migrated from Yarn Classic get that setting written for them.

## Yarn Berry's checksums hash its own archives

A Yarn Berry lockfile checksum is a SHA-512 hash of the zip archive Yarn builds from a package, not of the tarball the registry serves. The `10c0` prefix is Yarn's cache key: cache version 10, compression level 0. We compared the three hashes for `debug@4.4.1`:

```sh
$ grep -A11 '^"debug@npm:4.4.1"' yarn.lock
"debug@npm:4.4.1":
  version: 4.4.1
  resolution: "debug@npm:4.4.1"
  dependencies:
    ms: "npm:^2.1.3"
  peerDependenciesMeta:
    supports-color:
      optional: true
  checksum: 10c0/d2b44bc1afd912b49bb7ebb0d50a860dc93a4dd7d946e8de94abc957bb63726b7dd5aa48c18c2386c379ec024c46692e15ed3ed97d481729f929201e671fcd55
  languageName: node
  linkType: hard
$ sha512sum ~/.yarn/berry/cache/debug-npm-4.4.1-6eab84b9f7-10c0.zip | cut -c1-40
d2b44bc1afd912b49bb7ebb0d50a860dc93a4dd7
$ npm view debug@4.4.1 dist.integrity
sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==
```

The lockfile matches Yarn's cache file, and neither matches the registry's integrity string. A Yarn Classic lockfile stores the registry's own `integrity` value, so anyone can check it against npm. A Berry checksum only proves that the cache matches the [lockfile](https://socket.dev/glossary/lockfile). It cannot prove that either matches what the registry published.

That matters for lockfile poisoning, where a pull request edits `yarn.lock` to point a dependency somewhere the reviewer did not expect. Lockfile diffs are long and rarely read. We changed one entry so that `left-pad@^1.3.0` resolved to 1.1.3, a version outside the declared range, with 1.1.3's real checksum:

```sh
$ diff yarn.lock.orig yarn.lock
17,19c17,19
<   version: 1.3.0
<   resolution: "left-pad@npm:1.3.0"
<   checksum: 10c0/3fb59c76e281a2f5c810ad71dbbb8eba8b10c6cf94733dc7f27b8c516a5376cacea53543e76f6ae477d866c8954b27f1e15ca349424c2542474eb5bb1d2b6955
---
>   version: 1.1.3
>   resolution: "left-pad@npm:1.1.3"
>   checksum: 10c0/e0d6444c4ef94ee18e1f518b3e82f9b415cbfc12f1d16509cf09a8c67e508a9d0a1acdbfca1576cd758deaef22c2090f35c57dde98fd74fa7a7c11c51099691d
$ yarn install --immutable
➤ YN0000: · Done in 0s 72ms
$ yarn node -p "require('left-pad/package.json').version"
1.1.3
$ yarn install --immutable --check-resolutions
➤ YN0078: │ Invalid resolution left-pad@npm:^1.3.0 → npm:1.1.3
➤ YN0000: · Failed with errors in 0s 23ms
```

Yarn's hardened mode turns on `--check-resolutions` and `--refresh-lockfile` for every install. It is on by default only when Yarn detects a pull request from a public GitHub repository, according to [Yarn's security documentation](https://yarnpkg.com/features/security), which also warns that it makes installs significantly slower. Private repositories, pushes to the main branch, and other CI systems do not get it unless you set `enableHardenedMode: true` or `YARN_ENABLE_HARDENED_MODE=1`.

Hardened mode does not inspect archives that are already in a committed cache. Yarn's Zero-Installs setup commits `.yarn/cache` to Git so CI never downloads packages. We appended a line to `left-pad`'s `index.js` inside its cached zip, then updated the checksum in `yarn.lock` and the hash in the zip's file name to match. A hardened install passed, and the injected line ran on the next `require('left-pad')`. Only `--check-cache`, which refetches every package and compares, caught it:

```sh
$ YARN_ENABLE_HARDENED_MODE=1 yarn install --immutable
➤ YN0000: · Done in 0s 275ms
$ yarn install --immutable --check-cache
➤ YN0018: │ left-pad@npm:1.3.0: The remote archive doesn't match the local checksum - has the local cache been corrupted?
➤ YN0000: · Failed with errors in 0s 325ms
```

If you commit your cache, run `--check-cache` in CI on any pull request that touches `.yarn/cache`, or stop committing the cache.

## Does Yarn run install scripts?

Yarn 4.14.0 and later do not run dependency [install scripts](https://socket.dev/glossary/postinstall-scripts) in new projects. Yarn Classic and Yarn Berry 4.13 and earlier run every dependency's `preinstall`, `install`, and `postinstall` script by default. Install scripts are the classic execution point for malicious packages, because they run during install, before any code imports the package.

Yarn's lead maintainer, Maël Nison, merged the change in [yarnpkg/berry#7089](https://github.com/yarnpkg/berry/pull/7089) on March 31, 2026, and it shipped in 4.14.0 on April 16. He had planned to wait for the next major version but cited the regularity of package compromises: "I think we need to address it sooner than that." Of npm, pnpm, and Yarn, Yarn was the second to make the switch. [pnpm 10 blocked lifecycle scripts by default](https://socket.dev/blog/pnpm-10-0-0-blocks-lifecycle-scripts-by-default) in January 2025, and [npm v12](https://socket.dev/blog/npm-12) followed on July 8, 2026.

We packed a local package whose `postinstall` script writes a marker file, then installed it with the last release before the change and with the current one:

```sh
$ yarn --version
4.13.0
$ yarn add evil-demo@file:./evil-demo-1.0.0.tgz
➤ YN0007: │ evil-demo@file:./evil-demo-1.0.0.tgz must be built because it never has been before or the last one failed
$ cat PWNED
ran

$ yarn --version
4.18.1
$ yarn add evil-demo@file:./evil-demo-1.0.0.tgz
➤ YN0004: │ evil-demo@file:./evil-demo-1.0.0.tgz lists build scripts, but all build scripts have been disabled.
➤ YN0000: · Done with warnings in 0s 168ms
$ cat PWNED
cat: PWNED: No such file or directory
```

Yarn 4.18.1 skips the script with a warning and exits 0. The block also covers `binding.gyp`. [The Miasma waves of Mini Shai-Hulud](https://socket.dev/blog/miasma-mini-shai-hulud-hits-leoplatform-npm-packages-go-ecosystem) used a technique called Phantom Gyp: a `binding.gyp` file whose command expansion runs JavaScript when the package manager builds it as a native addon. We packed a package whose only content was such a file. Yarn 4.18.1 reported `YN0004`, and the payload never ran.

Approvals live in `package.json` under `dependenciesMeta`. Yarn accepts a bare package name or a name with an exact version. We approved `evil-demo@1.0.0`, and its script ran. When we bumped the dependency to 1.0.1, Yarn skipped the script again with `YN0004`. The version-pinned form is the one to use:

```json
{
  "dependenciesMeta": {
    "esbuild@0.25.5": { "built": true }
  }
}
```

A bare `"esbuild": { "built": true }` approves every future release, including one an attacker publishes with a stolen maintainer token.

Yarn 4.14.0 also added `approvedGitRepositories`, which defaults to an empty list. A new project refuses every [Git dependency](https://socket.dev/glossary/git-dependencies) until you allow its repository:

```sh
$ yarn add ms@github:vercel/ms
➤ YN0080: │ ms@github:vercel/ms: Request to 'https://github.com/vercel/ms.git' has been blocked because it doesn't match any of the patterns in 'approvedGitRepositories'
➤ YN0000: · Failed with errors in 0s 28ms
```

## Upgraded Yarn projects keep the old defaults

Upgraded Yarn projects keep the old defaults because Yarn writes them into `.yarnrc.yml` during migration. Yarn identifies an existing project by its lockfile version. When a newer binary meets an older lockfile, it applies migration patches that preserve the previous behavior. We created a project with Yarn 4.13.0, which writes lockfile version 8, and ran Yarn 4.18.1 on it:

```sh
$ cat .yarnrc.yml
cat: .yarnrc.yml: No such file or directory
$ yarn install
➤ YN0087: Migrated your project to the latest Yarn version 🚀

➤ YN0000: · Yarn 4.18.1
➤ YN0000: · Done in 0s 35ms
$ cat .yarnrc.yml
approvedGitRepositories:
  - "**"

enableScripts: true

npmMinimalAgeGate: 0
$ grep -A1 __metadata yarn.lock
__metadata:
  version: 10
```

The migration happens in two steps. Yarn 4.14 writes lockfile version 9 and pins `enableScripts: true` and `approvedGitRepositories: ["**"]`. Yarn 4.15.0 writes version 10 and adds `npmMinimalAgeGate: 0`. A Yarn Classic project upgraded straight to 4.18.1 gets all three keys plus `nodeLinker: node-modules`. We confirmed each case by running those binaries. After the upgrade, the `postinstall` script from our test package ran again with `YN0007`.

> Figure 2. One Yarn 4.18.1 binary, two sets of defaults. Tested November 1, 2026.
>
> The same Yarn 4.18.1 binary applies different security defaults to a new project and an upgraded one

Figure 2 as text. Both projects run Yarn 4.18.1. The new project has no security keys in `.yarnrc.yml`, so the built-in defaults apply: a dependency `postinstall` script is skipped with `YN0004`, `github:vercel/ms` is blocked with `YN0080`, and an exact pin to a TypeScript nightly published about seven hours earlier fails with `YN0016`. The project created with Yarn 4.13 or Yarn Classic gets `approvedGitRepositories: ["**"]`, `enableScripts: true`, and `npmMinimalAgeGate: 0` written into `.yarnrc.yml` by the upgrade: the `postinstall` script runs with `YN0007`, the Git dependency is allowed and resolved to a commit, and the fresh TypeScript pin installs with exit code 0.

Preserving old behavior was a defensible compatibility call. Doing it silently was not. The upgrade prints a rocket and nothing about the three security settings it just pinned, and those lines read like deliberate choices to the next person who opens the file. Check every Yarn Berry project you upgraded after April 16, 2026, and every project you moved off Yarn Classic. Deleting the three lines restores the 4.18.1 defaults; then approve the packages that need builds by exact version:

```yaml
# Delete these lines to get Yarn 4.18.1's defaults back
approvedGitRepositories:
  - "**"

enableScripts: true

npmMinimalAgeGate: 0
```

For CI, environment variables override `.yarnrc.yml`. We confirmed that `YARN_ENABLE_SCRIPTS=false` blocks scripts in a migrated project. A home-directory `~/.yarnrc.yml` does not work as a floor, because project settings override it.

## Does Yarn's npmMinimalAgeGate protect you?

Yarn's `npmMinimalAgeGate` protects new resolutions strictly and does nothing for versions already in `yarn.lock`. The setting is Yarn's [dependency cooldown](https://socket.dev/glossary/dependency-cooldown): the minimum age, in minutes, that a version must reach on the npm registry before Yarn will pick it. Yarn [added the setting in 4.10.0](https://socket.dev/blog/npm-introduces-minimumreleaseage-and-bulk-oidc-configuration) in September 2025, off by default. The 4.15.0 binary, published May 19, 2026, is the first release we found that defaults to 1440 minutes, one day. Yarn's documentation disagrees with itself here: its security page credits 4.12 and a default of `1d`, and its settings reference lists `1w`. We trust the binaries.

We tested the gate with Yarn 4.18.1 against TypeScript's nightly builds. The newest, `7.1.0-dev.20261101.1`, was published at 08:05 UTC on November 1, about seven and a half hours before our runs:

| Command, new project | Result |
| --- | --- |
| yarn add typescript@7.1.0-dev.20261101.1 | YN0016: all satisfying versions are quarantined, exit 1 |
| yarn add typescript@next | YN0016, exit 1 |
| yarn add typescript@^7.1.0-dev.20261030.1 | Installs 7.1.0-dev.20261031.1, about 31 hours old, exit 0 |
| yarn dlx -p typescript@7.1.0-dev.20261101.1 tsc --version | YN0016, exit 1 |
| yarn add typescript@7.1.0-dev.20261101.1 --no-time-gate | Installs the fresh build, exit 0 |
| yarn install --immutable, fresh build already in yarn.lock | Installs the fresh build, exit 0 |
| Same, with YARN\_ENABLE\_HARDENED\_MODE=1 | Installs the fresh build, exit 0 |

The first row is where Yarn beats [pnpm](https://socket.dev/glossary/pnpm). pnpm 12's default cooldown is soft: an exact pin to a fresh version installs, and pnpm writes the version into an exclusion list. Yarn refuses it, and `yarn dlx` follows the same rule.

The last three rows are the gap. The gate runs only during resolution, and an install from the lockfile does not resolve. `--no-time-gate` leaves no trace in configuration: the fresh version goes into `yarn.lock`, and every later `--immutable` install in CI accepts it, hardened or not. Our analysis of [the axios compromise](https://socket.dev/blog/hidden-blast-radius-of-the-axios-compromise) put the general rule plainly: "Lockfiles make existing installs deterministic. They do not make new installs safe." Review any pull request that changes `yarn.lock` or `npmPreapprovedPackages` as a dependency change, because it is one.

A cooldown buys detection time and nothing else. It delays legitimate security fixes, which is why `npmPreapprovedPackages` exists, and it does nothing against a malicious release that stays undetected for longer than a day.

## Is Yarn better than npm?

Yarn 4.18.1 is safer than npm 12 for a project created today, and weaker for an upgraded project until you delete the migrated settings. The npm 12 and pnpm 12 columns repeat what we measured with npm 12.2.1 and pnpm 12.15.0 on October 25, 2026, for our pnpm entry. The Yarn columns are from our November 1 runs.

| Default | Yarn 4.18.1, new project | Yarn 4.18.1, upgraded project | npm 12 | pnpm 12 |
| --- | --- | --- | --- | --- |
| Dependency install scripts | Off | On | Off | Off |
| Unapproved script during install | Skipped, YN0004, exit 0 | Runs | Skipped with a warning, exit 0 | Install fails, exit 1 |
| Approval written by default | Hand-edited; name or name@version | Not needed | Exact version | Package name only |
| Git dependencies | All refused | All allowed | All refused | Direct allowed, transitive refused |
| Release-age gate | 1440 minutes; exact pin fails | Off | Off | 1440 minutes; exact pin installs |
| Undeclared imports | Blocked by PnP | Blocked by PnP; allowed if migrated from Classic | Work if hoisted | Fail at the project root |

Our verdict: start new projects on Yarn 4.18.1 when your toolchain works under Plug'n'Play. It matches npm 12 on install scripts and Git dependencies, and adds a release-age gate that fails a fresh exact pin. Choose npm 12 when tools in your pipeline assume a flat `node_modules`, or when you want the fewest moving parts. If you already run Yarn Berry, the choice matters less than the audit: open `.yarnrc.yml`. If you still run Yarn Classic, migrate. It runs every dependency script, has no release-age gate, and has not had a release since March 2024.

## What Yarn's defaults do not stop

Yarn's defaults do not stop a compromised package that runs its payload when your code imports it. Every control above keys on how a package is delivered: its install hooks, its age, its source. None of them reads what the package contains. A malicious release that survives the one-day gate and runs at import passes all of them.

Publisher identity does not close that gap either. In [the OpenAPI React Query Codegen compromise](https://socket.dev/blog/openapi-react-query-codegen-npm-compromise), all ten malicious versions carried valid npm provenance issued through GitHub Actions trusted publishing. The first wave executed only through `binding.gyp`, which Yarn 4.14 and later block in a new project. A provenance check would have passed every one of them.

Closing that gap means checking contents before packages reach the cache. [Socket Firewall](https://docs.socket.dev/docs/socket-firewall-free) supports Yarn: prefixing the command, as in `sfw yarn install`, routes package downloads through a proxy that checks each requested artifact against Socket's analysis and blocks confirmed malware. Yarn's design creates two caveats. The firewall sees only network fetches, and Yarn's cache is shared across projects by default, so a version already in `~/.yarn/berry/cache` is not checked again. A Zero-Installs project never fetches at all, so its packages arrived through Git and must be checked there.

## Yarn's policy lives in the repository it polices

Yarn keeps its security policy in `.yarnrc.yml` and `package.json`, files that a pull request, a bot, or Yarn's own migration can edit. Project settings beat the home-directory config, so a developer cannot set a personal floor. Only environment variables can. The upgrade path proves the point: the one time Yarn wrote security settings on its own, it wrote the weakest values available.

We would like Yarn to print each security setting a migration pins, and to treat global and CI settings as a floor that project files can tighten but never relax. Until then, the safest Yarn project is a new one. For every other project, open `.yarnrc.yml` and read it.
