Back to glossary

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

Yarn is a JavaScript package manager that installs packages from the 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, 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, 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.

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

The Yarn 6 overview 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, 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, 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:

Bash
$ 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. 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:

Bash
$ 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 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:

Bash
$ 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. 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:

Bash
$ 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, 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:

Bash
$ 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 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 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 in January 2025, and npm v12 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:

Bash
$ 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 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 until you allow its repository:

Bash
$ 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:

Bash
$ 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.

Created with Yarn 4.18.1

New project

# .yarnrc.yml
# no security keys:
# built-in defaults apply
  • ✕Dependency postinstall: skipped, YN0004
  • ✕github:vercel/ms: blocked, YN0080
  • ✕Exact pin to a TypeScript build 7 hours old: fails, YN0016

Created with Yarn ≤4.13 or Yarn Classic

Upgraded to Yarn 4.18.1

# .yarnrc.yml (written by upgrade)
approvedGitRepositories:
  - "**"
enableScripts: true
npmMinimalAgeGate: 0
  • ✓Dependency postinstall: runs, YN0007
  • ✓github:vercel/ms: allowed, resolved to a commit
  • ✓Exact pin to a TypeScript build 7 hours old: installs, exit 0
Figure 2. One Yarn 4.18.1 binary, two sets of defaults. Tested November 1, 2026.

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: 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 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 projectResult
yarn add typescript@7.1.0-dev.20261101.1YN0016: all satisfying versions are quarantined, exit 1
yarn add typescript@nextYN0016, exit 1
yarn add typescript@^7.1.0-dev.20261030.1Installs 7.1.0-dev.20261031.1, about 31 hours old, exit 0
yarn dlx -p typescript@7.1.0-dev.20261101.1 tsc --versionYN0016, exit 1
yarn add typescript@7.1.0-dev.20261101.1 --no-time-gateInstalls the fresh build, exit 0
yarn install --immutable, fresh build already in yarn.lockInstalls the fresh build, exit 0
Same, with YARN_ENABLE_HARDENED_MODE=1Installs the fresh build, exit 0

The first row is where Yarn beats 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 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.

DefaultYarn 4.18.1, new projectYarn 4.18.1, upgraded projectnpm 12pnpm 12
Dependency install scriptsOffOnOffOff
Unapproved script during installSkipped, YN0004, exit 0RunsSkipped with a warning, exit 0Install fails, exit 1
Approval written by defaultHand-edited; name or name@versionNot neededExact versionPackage name only
Git dependenciesAll refusedAll allowedAll refusedDirect allowed, transitive refused
Release-age gate1440 minutes; exact pin failsOffOff1440 minutes; exact pin installs
Undeclared importsBlocked by PnPBlocked by PnP; allowed if migrated from ClassicWork if hoistedFail 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, 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 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.