SSD Nodes Learn 🎉 VPS from $5.50/mo
Guides Matt ConnorBy Matt Connor

npm Supply-Chain Attacks on Your Server

How npm supply-chain attacks reach a Node app on a VPS: malicious patch releases, postinstall scripts, typosquats, and the deploy practice that stops them.

What an npm supply-chain attack is on your server

An npm supply-chain attack reaches your server through a package you chose to install. There is no open port involved and no exploit step. npm (node package manager) installs code, and installing code runs code, so a small Node application pulls in several hundred packages you have never read, and any one of them can publish a new version an hour from now.

Your deploy fetches a malicious version because your install command asked for the newest matching version. That code then runs with the privileges of whoever ran the install. Everything below follows from those two sentences.

The shapes are ordered by how often they hit one person deploying one Node app to one VPS. That order is not the one a large company would use, because a large company has an internal registry, a review team and a mirror of the public registry. You have a deploy script.

Shape 1: a maintainer account is compromised and publishes a patch

The npm registry does not let anyone change the contents of a version that already exists. An attacker who phishes a maintainer or steals a publish token therefore cannot rewrite 4.18.2. They publish 4.18.3.

Look at your package.json. A line like "express": "^4.18.2" does not mean version 4.18.2. The caret means "any 4.x version at or above this one", and ~4.18.2 means "any 4.18.x". npm install resolves that range at the moment it runs, so the same git commit, deployed twice on the same afternoon, can install two different sets of code. That gap is the attack surface. Nothing on your machine has to be compromised for it to open.

Malicious releases are usually reported and pulled, but the pull happens after people have installed them. Whoever deployed during the window has the code on disk. A pipeline that resolves ranges on every run enters that window automatically, several times a week, without anyone deciding to.

Shape 2: an install script runs as the user doing the deploy

A package's package.json can declare preinstall, install, postinstall and prepare in its scripts block. npm runs them during installation. They are not sandboxed and nobody reviews them. They are shell commands running as the user who typed the install command, in that user's home directory, with that user's network access and the full environment of that shell.

So the useful question is not what the package can do. It is what that user can read. On a normal deploy box the answer includes ~/.npmrc holding a registry token, ~/.ssh/id_ed25519 used as a deploy key for SSH (secure shell), ~/.aws/credentials, ~/.docker/config.json, and every exported variable in the shell, which is where DATABASE_URL usually lives.

A payload like this needs no persistence and no privilege escalation. It reads a few files, sends them to a host over HTTPS, and exits with status 0. You see nothing, because npm hides install-script output by default. Turn that off and watch what actually runs:

npm ci --foreground-scripts

foreground-scripts shares standard input, output and error with the npm process, so build scripts print into your terminal instead of into a buffer npm discards when the install succeeds.

Shape 3: typosquats, and the name you did not quite type

A typosquat is a package published under a name close to a popular one, waiting for a mistyped or mis-pasted install command. The mechanism is the command, not the code, so a lockfile does not help you here: you add the wrong name once, and from then on the lockfile faithfully pins it.

The variant that catches teams rather than individuals is dependency confusion. Your internal package is called billing-utils and lives on a private registry. If nothing named billing-utils exists on the public registry, anyone can publish one. npm resolves unscoped names against the default public registry, so the public copy can win. The fix is a scope you own plus a registry mapping for that scope, in .npmrc:

@yourorg:registry=https://npm.yourorg.example/
//npm.yourorg.example/:_authToken=${NPM_TOKEN}

Now @yourorg/billing-utils is only ever fetched from that host, because the scope-to-registry mapping is consulted before the default registry. An unscoped internal name has no mapping, so it has no protection.

Before you add any new dependency, look at it rather than at its download badge:

npm view some-lib repository.url maintainers time.created time.modified

A package created last month, published by an account you cannot tie to a public repository, is a different risk from one with six years of history. Neither fact is proof. Both are cheap to check.

Shape 4: the dependency whose owner quietly changed

Maintainers hand packages over. Someone burns out, a stranger offers to help, publish rights move, and no notification of any kind reaches the projects that depend on it. Nothing is compromised. The trust you extended in 2021 is now held by a different person.

This is the slowest shape and the hardest to detect, and no command answers it directly. Two things narrow it. Check who can publish before you adopt a package, with the npm view line above. Then read the diff when a package you actually depend on moves:

npm diff --diff-name-only --diff=some-lib@1.4.2 --diff=some-lib@1.4.3
npm diff --diff=some-lib@1.4.2 --diff=some-lib@1.4.3

The first form prints only the changed filenames, which is fast enough to do on every upgrade of a package you care about. A patch release that touches a build script, adds a file at the package root, or edits the scripts block is worth reading in full before it reaches your server.

Build from a committed lockfile with npm ci

package-lock.json records the exact version of every package in the tree, the URL each one came from, a sha512 integrity hash of each tarball, and which package required it. Commit it. It is the only file that says what you actually tested.

Then install with npm ci, never npm install, on any machine that is not a developer laptop:

npm ci --omit=dev --ignore-scripts

npm ci differs from npm install in ways that all matter here. It requires a lockfile to exist. It removes any existing node_modules before it starts, so leftovers from an earlier deploy cannot survive into this one. It never writes to package.json or to the lockfile, so an install cannot quietly move you forward a version. If the lockfile and package.json disagree, it exits with an error instead of resolving the difference.

That error is the feature, not an annoyance. It means a dependency change has to arrive as a commit somebody reviewed, and not as a side effect of a deploy at 02:00.

The integrity hash is checked on every fetch. A tarball whose bytes do not match the recorded hash fails the install with code EINTEGRITY rather than unpacking. Be precise about what that buys you: it proves the file you received is the file the lockfile pinned, which is the same guarantee verifying downloads with checksums gives you, and it is limited in the same way. It says nothing about whether the pinned version was malicious when it was published.

One detail about --omit=dev: those packages are still resolved and still written into the lockfile. They are just not placed on disk. Fewer packages on disk means fewer install scripts and less code loaded at runtime, so it is worth doing. It does not remove a dependency from your tree.

Treat install scripts as code, and know how to refuse them

You can turn install scripts off. Put this in the project's .npmrc and commit it next to the lockfile:

ignore-scripts=true
save-exact=true

ignore-scripts=true stops npm running the scripts declared in dependencies. save-exact=true makes npm install some-lib write 1.4.2 into package.json instead of ^1.4.2, so a resolving range never enters your manifest by accident.

This breaks things, and you should know how before you enable it. Packages that compile a native addon or download a prebuilt binary do that work in an install script. With scripts off, the install itself succeeds and the failure appears later, at runtime, as a module that cannot load its binding file. The answer is an allowlist:

npm ci --ignore-scripts
npm rebuild better-sqlite3

npm rebuild <package> runs the build scripts for that one package. You have now made a decision per package, instead of granting blanket execute permission to a few hundred strangers you will never meet.

To see how large that grant currently is, ask npm:

npm query ":attr(scripts, [postinstall])"

That prints every package in the installed tree that carries a postinstall script. On a typical application the list is shorter than people expect, which is exactly what makes the allowlist practical.

Separate the build from the process serving traffic

The deploy user needs to write node_modules. The process answering HTTP requests does not. If they are the same account, then code that runs during install can rewrite the code that serves your users, and code that runs at runtime can rewrite it too.

Split them. Build as one user, serve as another, and make the served directory read-only to the serving account:

sudo useradd --system --home-dir /srv/nodeapp --shell /usr/sbin/nologin nodeapp
sudo chown -R deploy:nodeapp /srv/nodeapp
sudo chmod -R o-rwx /srv/nodeapp

Then let systemd enforce it. Write /etc/systemd/system/nodeapp.service:

[Unit]
Description=Node application
After=network-online.target

[Service]
User=nodeapp
Group=nodeapp
WorkingDirectory=/srv/nodeapp/current
EnvironmentFile=/etc/nodeapp/env
ExecStart=/usr/bin/node server.js
Restart=on-failure

NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=/srv/nodeapp/shared
NoExecPaths=/srv/nodeapp/shared
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX

[Install]
WantedBy=multi-user.target

ProtectSystem=strict mounts the entire file system read-only for this service, except /dev, /proc, /sys and whatever you list in ReadWritePaths. So an attempt by the application to write into node_modules fails with EROFS: read-only file system, which you can go and reproduce in your own logs in about a minute. NoExecPaths covers the writable upload directory: the service can write files there and the kernel refuses to execute them. That option needs systemd 249 or newer, and Ubuntu 24.04 ships 255.

Two traps in this unit file. First, do not add MemoryDenyWriteExecute=yes. It appears in most systemd hardening lists, and it stops Node from starting, because V8 compiles JavaScript to machine code at runtime and needs pages that are both writable and executable. Second, take the ExecStart path from command -v node. If Node was installed with a version manager it lives under the deploy user's home directory, ProtectHome=yes then hides that directory from the service, and the unit fails immediately with status=203/EXEC and a log line saying the executable could not be located.

Check the result rather than trusting the file:

sudo systemctl daemon-reload
sudo systemctl enable --now nodeapp
sudo systemd-analyze security nodeapp.service
sudo -u nodeapp touch /srv/nodeapp/current/probe

systemd-analyze security lists every hardening setting with its exposure, so you can see which ones are still at the default. The touch should fail with Permission denied, because nodeapp owns nothing under current. If it succeeds, your file ownership is wrong and the systemd settings are quietly covering for it.

One note on EnvironmentFile: systemd reads it as root before it drops to User=nodeapp, so that file can be root:root with mode 600. The application still receives the variables. Anyone with a shell as nodeapp can still read them from /proc/<pid>/environ, so this protects the secret at rest and not the running process.

Keep deploy credentials out of the build environment

Install scripts inherit the environment. That one fact should decide where you build.

The strongest version is to build somewhere that is not the production server and copy the finished directory across. The build machine then holds a read-only registry token and nothing else. No SSH deploy key, no cloud access key, no database password, no container registry login.

npm token create --read-only

A read-only token can fetch packages and cannot publish. If it is stolen out of a build environment, the loss is the ability to download public packages.

If you must build on the server, build as the deploy user with a deliberately narrow environment, and keep the runtime secrets in /etc/nodeapp/env, which deploy cannot read. The same reasoning applies to build automation you host yourself: a self-hosted GitHub Actions runner holds tokens and executes arbitrary published code on every job, which makes it the highest-value machine in a small deployment. Any program you did not write that receives your whole environment belongs in the same category, which is why keeping secrets out of an AI agent's environment is this problem with a different program in the middle.

Pin or vendor what you cannot audit

A pinned dependency is one whose version cannot change without a commit. The committed lockfile already does that for the whole tree. Two cases need more.

Transitive dependencies are the first. You do not control what your dependencies depend on. overrides in package.json forces a version anywhere in the tree:

{
  "overrides": {
    "some-transitive-lib": "1.4.2"
  }
}

Run npm install once after adding it so the lockfile records the result, then commit both files.

The second case is a package you cannot audit and cannot drop. Vendor it. npm pack downloads the exact tarball the registry would serve, and a file: dependency installs from your copy:

npm pack some-lib@1.4.2
mkdir -p vendor && mv some-lib-1.4.2.tgz vendor/
{
  "dependencies": {
    "some-lib": "file:vendor/some-lib-1.4.2.tgz"
  }
}

The tarball now lives in your repository and cannot change under you. You have also taken on its updates forever, so use this for the small abandoned package you are stuck with, not for your web framework.

There is also a cooling-off period, which costs nothing:

npm install --before=2026-08-01

The before option rebuilds the tree using only versions that were published on or before that date. Set it a week or two back when you refresh dependencies, and you skip the window in which a bad release is live and not yet reported. It is a blunt tool, because it also holds back genuine security fixes. Use it to resolve the ranges, read what changed, then commit the lockfile.

How do I know which version I actually shipped?

The lockfile in git says what should have been installed. The disk says what is installed. Only the second one is evidence.

npm ls some-lib
node -e "console.log(require('./node_modules/some-lib/package.json').version)"

npm ls reads node_modules, so it reports what is physically present rather than what the lockfile intended. The node -e line reads the installed manifest by path, which works even for packages whose exports field blocks subpath imports, and prints one version with no tree drawing around it.

For the other half of the comparison, read git:

git log --oneline -- package-lock.json
git show <commit>:package-lock.json | grep -A3 '"node_modules/some-lib"'

Make the connection between the two permanent by putting the commit into the deploy layout. Release into /srv/nodeapp/releases/<short commit sha> and point /srv/nodeapp/current at it with a symlink. The answer to "what is running right now" becomes readlink /srv/nodeapp/current, and it is available at 03:00 to somebody who was not the person who deployed it.

Finally, check what the registry will vouch for:

npm audit signatures

This verifies registry signatures on the packages in your installed tree, and verifies provenance attestations for packages that have them. Provenance ties a published tarball to the public continuous integration (CI) build that produced it, so a verified attestation means you can trace the code back to a commit rather than to an unknown laptop. Coverage is not universal, so read a missing attestation as "no information", not as "bad package".

What to do after a bad release reaches your server

Work outward from what ran, and as which user.

If the code ran during install, assume everything readable by the build user is gone. Rotate the registry token, the SSH keys in that home directory, the cloud credentials, and any secret that was exported in that shell. Rotation is the only honest response, because you cannot prove a file was not read.

If the code ran at runtime under a locked-down service account, the reachable set is much smaller: the application's own environment variables and whatever its network access can reach. That is the entire argument for running services as unprivileged users on a VPS. It does not prevent the compromise. It decides how much of the machine the compromise gets, and whether it survives a restart.

Then rebuild instead of cleaning. Delete node_modules, pin the affected package below the bad version in package.json, run npm install once to update the lockfile, commit it, and deploy with npm ci. Do not repair a tree in place. You cannot enumerate what an install script touched.

Write down the window as well: the first deploy that could have pulled the version, and the deploy that removed it. That range tells you which of your own logs to read, and it is only answerable if your releases are named after commits.

What none of this fixes

A lockfile does not make a dependency safe. It turns the moment you accepted that dependency into a dated, reviewed decision instead of a side effect of a deploy. Every practice above performs the same conversion, from accident to choice.

npm audit is not a defence here. It compares your tree against a database of reported vulnerabilities, so it finds problems that have already been published and named. A supply-chain attack is unnamed for its entire useful life. Run npm audit for old known bugs and expect nothing from it about a release that shipped four hours ago.

Reducing your dependency count helps more than any tool in this guide, and it is the least popular advice anyone gives. Every package you do not add is one more publisher who cannot be phished on your behalf, and one more install script that never runs as your deploy user.

None of this is specific to npm either. The same four shapes apply to PyPI, RubyGems, container images and your distribution's package manager. npm is where it shows up most because the trees are deepest and install scripts run by default. How much of the surrounding machine is yours to defend depends on where it runs, which is part of the wider question of whether VPS hosting is safe.

FAQ

Does npm ci protect me from a compromised npm package?

It protects you from the version changing without your knowledge. npm ci installs exactly what package-lock.json records, checks each tarball against its sha512 integrity hash, and exits with an error if package.json and the lockfile disagree rather than resolving the difference. It says nothing about whether the pinned version is safe. If you commit a lockfile that pins a malicious version, npm ci will faithfully install that version on every server you own, every time.

Should I set ignore-scripts=true for everything?

Set it, then allowlist. ignore-scripts=true in the project's .npmrc stops dependency install scripts running, which removes the most direct path from a bad package to your deploy user's credentials. Packages that compile a native addon or fetch a prebuilt binary genuinely need theirs, and with scripts off they fail later at runtime with a missing binding file instead of at install time. Run npm ci --ignore-scripts, then npm rebuild <package> for the few packages you decided to trust. npm query ":attr(scripts, [postinstall])" shows how many there really are.

How do I find out which version of a package my server actually installed?

Read the disk, not the lockfile. npm ls <package> reports what is present in node_modules, and node -e "console.log(require('./node_modules/<package>/package.json').version)" prints just the version string. The lockfile in git answers a different question, which is what should have been installed, and comparing the two is the point. Deploying into a directory named after the git commit keeps both answers available months later, when you need them.

Does npm audit find supply-chain attacks?

No. npm audit matches your tree against a database of reported vulnerabilities, so it only finds issues that have already been published and given an identifier. A malicious release is unreported during the hours or days when installing it matters. npm audit signatures is the more useful command: it verifies registry signatures across your installed tree and checks provenance attestations where the publisher produced them, which tells you a tarball came from a public build rather than from an unknown machine.

Why does running the app as an unprivileged user matter if the attack happens at install time?

Because the two failures have different reach and you are defending against both. Install-time code runs as the deploy user and can read that user's SSH keys, registry tokens and cloud credentials. Runtime code runs as the service account, and with User=nodeapp, ProtectSystem=strict and no credentials on disk it can read, its reach stops at the application's own environment and its database. Separating the accounts also means the process serving traffic cannot rewrite node_modules, so a runtime compromise is gone at the next restart instead of becoming permanent.