How npm Supply-Chain Attacks Fit Reach Your Server
See how malicious npm patch releases, postinstall scripts, and typosquats fit reach your Node app on a VPS, plus the deploy practice wey stops them.
Wetin npm supply-chain attack mean for your server
npm supply-chain attack fit reach your server through package wey you choose install. No open port dey involved, and no exploit step dey needed. npm (node package manager) dey install code, and when you install code, the code go run. So, small Node application fit pull several hundred packages wey you never read, and any one of dem fit publish new version one hour from now.
Your deploy fetch malicious version because your install command ask for the newest matching version. That code go run with the privileges of whoever run the install. Everything below come from those two sentences.
The different forms dey arranged based on how often dem affect one person wey dey deploy one Node app to one VPS. Large company no go use this same order, because e get internal registry, review team, and mirror of the public registry. You get deploy script.
Shape 1: maintainer account breach go publish patch
The npm registry no dey allow anybody change the content of version wey don already exist. So, if attacker phish maintainer or steal publish token, e no fit rewrite 4.18.2. Instead, dem publish 4.18.3.
Look your package.json. Line like "express": "^4.18.2" no mean version 4.18.2. The caret mean “any 4.x version wey be this one or newer”, while ~4.18.2 mean “any 4.18.x”. npm install go resolve that range when e run, so the same git commit wey you deploy twice for the same afternoon fit install two different sets of code. Na that gap be the attack surface. Nothing for your machine need to get breached before the gap open.
People dey usually report malicious releases and pull dem, but dem go pull am only after people don install am. Anybody wey deploy during that window don get the code for disk. Pipeline wey resolve ranges for every run go enter that window automatically, several times every week, without anybody deciding to.
Shape 2: install script dey run as the user wey dey do the deploy
A package's package.json fit declare preinstall, install, postinstall and prepare inside im scripts block. npm dey run dem during installation. Dem no dey sandbox, and nobody dey review dem. Dem na shell commands wey dey run as the user wey type the install command, inside that user's home directory, with that user's network access and the full environment of that shell.
So the useful question no be wetin the package fit do. Na wetin that user fit read. For normal deploy box, the answer include ~/.npmrc wey dey hold registry token, ~/.ssh/id_ed25519 wey dey use as deploy key for SSH (secure shell), ~/.aws/credentials, ~/.docker/config.json, and every exported variable for the shell. Na there DATABASE_URL dey usually live.
Payload like this no need persistence and no need privilege escalation. E go read some files, send dem go host over HTTPS, then exit with status 0. You no go see anything because npm dey hide install-script output by default. Turn am off and monitor wetin really dey run:
npm ci --foreground-scriptsforeground-scripts dey share standard input, output and error with the npm process. Because of this, build scripts go print for your terminal instead of inside buffer wey npm dey discard when installation succeed.
Shape 3: typosquat, na di name wey you no type well
Typosquat na package wey dem publish with name wey resemble popular package, dey wait for person to type or paste install command wrongly. Na the command cause the problem, no be the code, so lockfile no fit help you here: you add the wrong name once, then lockfile go faithfully pin am from that time onward.
The variant wey dey catch teams instead of individual users na dependency confusion. Your internal package name na billing-utils, and e dey for private registry. If nobody create package wey name billing-utils for public registry, anybody fit publish one. npm dey resolve unscoped names against the default public registry, so the public copy fit win. The fix na scope wey you own plus registry mapping for that scope, inside .npmrc:
@yourorg:registry=https://npm.yourorg.example/
//npm.yourorg.example/:_authToken=${NPM_TOKEN}Now @yourorg/billing-utils go only ever fetch from that host, because system go check the scope-to-registry mapping before the default registry. Unscoped internal name no get mapping, so e no get this protection.
Before you add any new dependency, inspect am instead of only checking the download badge:
npm view some-lib repository.url maintainers time.created time.modifiedPackage wey dem create last month and publish by account wey you no fit link to public repository get different risk from package wey get six years of history. None of these facts prove anything by themselves. But both checks cheap to do.
Shape 4: dependency wey the owner change quietly
Maintainers dey hand packages over. Person fit tire, another person offer to help, publishing rights move, and no notification reach the projects wey depend on am. Nothing don breach. The trust wey you give for 2021 now dey with another person.
This na the slowest shape and the hardest one to detect, and no command fit answer am directly. Two things fit narrow am. Check who fit publish before you adopt a package, with the npm view line above. Then read the diff when package wey you really depend on change:
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.3The first form dey print only the filenames wey change. E fast enough to run am for every upgrade of package wey matter to you. If patch release touch build script, add file for package root, or edit the scripts block, read am fully before e reach your server.
Build from committed lockfile with npm ci
package-lock.json dey record the exact version of every package for the tree, the URL wey each one come from, a sha512 integrity hash for each tarball, and which package require am. Commit am. Na the only file wey talk wetin una actually test.
Then install with npm ci, never npm install, for any machine wey no be developer laptop:
npm ci --omit=dev --ignore-scriptsnpm ci different from npm install for ways wey all matter here. E require make lockfile dey exist. E remove any existing node_modules before e start, so leftovers from earlier deploy no fit survive enter this one. E no ever write to package.json or to the lockfile, so install no fit quietly move you forward to another version. If lockfile and package.json no agree, e go exit with error instead of resolving the difference.
That error na the feature, no be annoyance. E mean say dependency change must come as commit wey person review, no be side effect of deploy for 02:00.
Dem dey check the integrity hash for every fetch. If tarball bytes no match the recorded hash, install go fail with code EINTEGRITY instead of unpacking am. Make you understand exactly wetin this one give you: e prove say the file wey you receive na the same file wey lockfile pin, and na the same guarantee verifying downloads with checksums dey give you. E get the same limitation too. E no talk anything about whether the pinned version malicious when dem publish am.
One detail about --omit=dev: dem packages still dey resolve and still dey write inside lockfile. Na only say dem no dey place am for disk. Fewer packages for disk mean fewer install scripts and less code wey load for runtime, so e worth doing. E no remove dependency from your tree.
Treat install scripts like code, and know when to reject dem
You fit disable install scripts. Put this for the project .npmrc and commit am together with the lockfile:
ignore-scripts=true
save-exact=trueignore-scripts=true stop npm from running the scripts wey dependencies declare. save-exact=true make npm install some-lib write 1.4.2 inside package.json instead of ^1.4.2, so a resolving range no go enter your manifest by mistake.
This one fit break things, so you need understand how before you enable am. Packages wey compile native addon or download prebuilt binary dey do that work inside install script. When scripts dey off, the install itself go succeed, but the failure go show later for runtime as a module wey no fit load its binding file. The solution na allowlist:
npm ci --ignore-scripts
npm rebuild better-sqlite3npm rebuild <package> run the build scripts for that one package. Now you don make decision for each package, instead of giving blanket execute permission to hundreds strangers wey you never go meet.
To see how big that permission currently be, ask npm:
npm query ":attr(scripts, [postinstall])"That one go print every package for the installed tree wey get postinstall script. For typical application, the list dey shorter than people expect. Na this one make allowlist practical.
Build go separate from process wey dey serve traffic
The deploy user need write access to node_modules. The process wey dey answer HTTP requests no need am. If na the same account, code wey runs during install fit rewrite the code wey dey serve your users, and code wey runs at runtime fit rewrite am too.
Separate dem. Build as one user, serve as another, and make the served directory read-only for 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/nodeappThen make systemd enforce am. 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.targetProtectSystem=strict mounts the entire file system as read-only for this service, except /dev, /proc, /sys and anything wey you list for ReadWritePaths. So if the application try write inside node_modules, e go fail with EROFS: read-only file system. You fit reproduce this for your own logs in about one minute. NoExecPaths covers the writable upload directory: the service fit write files there, but the kernel no go allow am execute dem. This option need systemd 249 or newer, and Ubuntu 24.04 ships 255.
Two traps dey inside this unit file. First, no add MemoryDenyWriteExecute=yes. E dey most systemd hardening lists, but e stops Node from starting because V8 dey compile JavaScript to machine code at runtime and need pages wey writable and executable at the same time. Second, take the ExecStart path from command -v node. If you install Node with a version manager, e dey under the deploy user's home directory. ProtectHome=yes go then hide that directory from the service, and the unit go fail immediately with status=203/EXEC plus a log line wey say e no fit locate the executable.
Check the result instead of 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/probesystemd-analyze security lists every hardening setting together with its exposure, so you fit see the ones wey still dey use default. The touch suppose fail with Permission denied because nodeapp own nothing under current. If e succeed, your file ownership no correct, and the systemd settings dey quietly cover for the problem.
One thing about EnvironmentFile: systemd reads am as root before e drops to User=nodeapp, so that file fit be root:root with mode 600. The application still receives the variables. Anybody wey get shell as nodeapp still fit read dem from /proc/<pid>/environ, so this protects the secret when e dey stored, but e no protect am from the running process.
Build environment no suppose get deploy credentials
Install scripts dey inherit environment. Na this fact suppose determine where you go build.
The strongest option na to build for somewhere wey no be production server, then copy the completed directory across. The build machine go hold read-only registry token only, and nothing else. No SSH deploy key, no cloud access key, no database password, no container registry login.
npm token create --read-onlyRead-only token fit fetch packages but e no fit publish. If person steal am from build environment, the loss na only the ability to download public packages.
If you must build for the server, build as the deploy user with environment wey you deliberately keep narrow, and keep runtime secrets inside /etc/nodeapp/env, wey deploy no fit read. The same reasoning apply to build automation wey you host yourself: self-hosted GitHub Actions runner dey hold tokens and execute any published code for every job, so e become the machine with the highest value for small deployment. Any program wey you no write but wey receive your complete environment belong to the same category. Na why keeping secrets out of an AI agent environment be this same problem with another program in the middle.
Pin or vendor wetin you no fit audit
Pinned dependency na dependency wey version no fit change unless commit happen. The committed lockfile don already do this for the whole dependency tree. Two cases need extra control.
Transitive dependencies na the first one. You no control wetin your dependencies depend on. overrides for package.json go force one version anywhere for the tree:
{
"overrides": {
"some-transitive-lib": "1.4.2"
}
}Run npm install once after you add am, so lockfile fit record the result. Then commit both files.
The second case na package wey you no fit audit and you no fit remove. Vendor am. npm pack downloads the exact tarball wey registry for serve, and file: dependency installs from your own 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 dey inside your repository and e no fit change without your control. But you don also take responsibility for all its future updates. So use this for small abandoned package wey you must keep, not for your web framework.
Cooling-off period dey too, and e no cost anything:
npm install --before=2026-08-01The before option rebuilds the tree with only versions wey dem publish on or before that date. Set the date one or two weeks back when you dey refresh dependencies. This skips the period when bad release dey live but nobody don report am yet. Na blunt tool, because e also delays genuine security fixes. Use am to resolve the version ranges, read wetin change, then commit the lockfile.
How you fit know which version you actually release?
The lockfile for git dey talk wetin suppose install. The disk dey talk wetin dey installed. Na only the second one be evidence.
npm ls some-lib
node -e "console.log(require('./node_modules/some-lib/package.json').version)"npm ls dey read node_modules, so e dey report wetin physically dey present instead of wetin the lockfile intend. The node -e line dey read the installed manifest by path. E still work for packages wey their exports field dey block subpath imports, and e print one version without tree drawing around am.
For the other side 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 both ones permanent by putting the commit inside the deploy layout. Release go /srv/nodeapp/releases/<short commit sha> and point /srv/nodeapp/current to am with a symlink. The answer to “wetin dey run right now” become readlink /srv/nodeapp/current, and e dey available for 03:00 to person wey no be the person wey deploy am.
Finally, check wetin the registry fit vouch for:
npm audit signaturesThis one verify registry signatures for the packages inside your installed tree, and e verify provenance attestations for packages wey get dem. Provenance connect published tarball to the public continuous integration (CI) build wey produce am, so verified attestation mean say you fit trace the code back to a commit instead of an unknown laptop. Coverage no universal, so treat missing attestation as “no information”, not as “bad package”.
Wetin to do after bad release reach your server
Start from wetin run, and which user run am.
If the code run during install, assume say everything wey build user fit read don comot. Rotate the registry token, the SSH keys for that home directory, the cloud credentials, and every secret wey shell export. Rotation na the only honest response, because you no fit prove say nobody read the file.
If the code run during runtime under locked-down service account, the things e fit reach dey much smaller: the application's own environment variables and anything wey e network access fit reach. Na this be the whole reason for running services as unprivileged users on a VPS. E no prevent the compromise. E decide how much of the machine the compromise go get, and whether e go survive restart.
Then rebuild instead of cleaning. Delete node_modules, pin the affected package below the bad version for package.json, run npm install once to update the lockfile, commit am, then deploy with npm ci. No repair tree in place. You no fit list everything wey install script touch.
Write down the window too: the first deploy wey fit don pull the version, and the deploy wey remove am. That range go tell you which of your own logs to read, and you fit only answer am if your releases get commit names.
Wetin none of dis fit fix
A lockfile no make dependency safe. E turn the time wey you accept that dependency into a dated, reviewed decision, instead make e be side effect of deploy. Every practice above dey make the same change, from accident to choice.
npm audit no be defence for here. E compare your tree with database of reported vulnerabilities, so e find problems wey dem don already publish and name. A supply-chain attack no get name for the whole time wey e useful. Run npm audit for old known bugs, and no expect am to tell you anything about release wey ship four hours ago.
To reduce the number of your dependencies help pass any tool for dis guide, but na the advice wey people like least. Every package wey you no add na one more publisher wey no fit get phished on your behalf, and one more install script wey no go ever run as your deploy user.
None of dis dey apply only to npm. The same four patterns apply to PyPI, RubyGems, container images, and your distribution's package manager. E dey show for npm pass because the trees deep well-well and install scripts dey run by default. How much of the surrounding machine na your responsibility to defend depend on where e dey run. Dis na part of the bigger question of whether VPS hosting safe.
FAQ
npm ci dey protect me from compromised npm package?
E dey protect you make version no change without your knowledge. npm ci install exactly wetin package-lock.json record, check each tarball against its sha512 integrity hash, and exit with error if package.json and the lockfile no agree, instead of trying to resolve the difference. E no talk whether the pinned version safe. If you commit lockfile wey pin malicious version, npm ci go faithfully install that version for every server wey you own, every time.
I suppose set ignore-scripts=true for everything?
Set am, then allowlist. ignore-scripts=true for the project .npmrc stop dependency install scripts from running. This remove the most direct path from bad package reach your deploy user credentials. Packages wey compile native addon or fetch prebuilt binary genuinely need their own scripts. If scripts dey off, dem go fail later for runtime with missing binding file instead of failing during install. Run npm ci --ignore-scripts, then run npm rebuild <package> for the few packages wey you decide to trust. npm query ":attr(scripts, [postinstall])" show how many dem really be.
How I fit know which version of package my server actually install?
Read wetin dey for disk, no be the lockfile. npm ls <package> report wetin dey present for node_modules, and node -e "console.log(require('./node_modules/<package>/package.json').version)" print only the version string. The lockfile for git answer another question: wetin suppose install. Comparing both na the important part. If you deploy inside directory wey carry the git commit name, both answers go still dey available months later when you need dem.
npm audit fit find supply-chain attacks?
No. npm audit compare your tree with database of reported vulnerabilities, so e only find issues wey people don publish and give identifier. Malicious release fit remain unreported during the hours or days when installing am matters. npm audit signatures na the more useful command: e verify registry signatures across your installed tree and check provenance attestations where the publisher provide dem. This tell you whether tarball come from public build or from unknown machine.
Why e matter to run the app as unprivileged user if the attack happen during install?
Because the two failures get different reach, and you dey defend against both. Code wey run during install run as deploy user, and e fit read that user's SSH keys, registry tokens, and cloud credentials. Runtime code run as service account. With User=nodeapp, ProtectSystem=strict, and no credentials for disk wey e fit read, its reach stop for the application's own environment and its database. Separating the accounts also mean the process wey dey serve traffic no fit rewrite node_modules. So if runtime compromise happen, e go disappear at the next restart instead of becoming permanent.