DEV Community

Cover image for npm Supply Chain Security: Mistakes I Made Publishing My First Packages
Alan
Alan

Posted on

npm Supply Chain Security: Mistakes I Made Publishing My First Packages

I published four npm packages from a pnpm monorepo in March. Node 22, TypeScript, ~4k lines across the four packages, eleven direct dependencies total. First time publishing anything to npm. Within two weeks I'd almost shipped a .env.example, missed a provenance setting that fails with zero output, and found out that 2FA on npm is basically theater once you start using automation tokens.

postinstall

Before my first publish I went through every dependency's package.json looking for lifecycle scripts. Took about an hour. The reason: ua-parser-js in 2021, colors + faker in 2022, @ledgerhq/connect-kit in 2023. All compromised through npm. All exploited postinstall.

The attack is dead simple:

{
  "scripts": {
    "postinstall": "node ./setup.js"
  }
}
Enter fullscreen mode Exit fullscreen mode

Runs on npm install. No prompt, no sandbox. Full user permissions. Read env vars, POST them somewhere, done.

pnpm doesn't run lifecycle scripts from deps by default. npm and yarn do. That alone is a reason to use pnpm, honestly.

To see which deps declare postinstall:

$ npm query ':has(> .scripts[postinstall])'
Enter fullscreen mode Exit fullscreen mode

npm's CSS-selector query syntax. I had to find it in the npm docs because nobody talks about it. Found two packages with postinstall in my tree: esbuild and protobufjs. Both legitimate. But you don't know that until you check.

Provenance (the silent failure that got me)

npm has had provenance attestations since 2023. One flag on publish:

$ npm publish --provenance
Enter fullscreen mode Exit fullscreen mode

Green checkmark on npmjs.com. Links the tarball to a specific GitHub Actions run and commit hash.

I didn't set this up for my first few publishes. Was running npm publish from my laptop. Provenance needs OIDC, so it only works inside CI (GitHub Actions, GitLab, CircleCI). Can't fake it locally.

The key part of my GitHub Actions workflow:

permissions:
  contents: read
  id-token: write  # THIS. Without it, provenance silently fails.

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          registry-url: 'https://registry.npmjs.org'
      - run: pnpm install --frozen-lockfile
      - run: pnpm -r build
      - run: npm publish --provenance --access public
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Enter fullscreen mode Exit fullscreen mode

I spent 30 minutes refreshing the npmjs.com page after my first CI publish, wondering where the green checkmark was. Re-ran the workflow. Published another version. Nothing. Checked the Actions log for errors. Clean. Turns out id-token: write was missing and --provenance just... silently doesn't attest. One line of YAML.

2FA doesn't protect what you think it does

I enabled auth-and-writes on day one:

$ npm profile set auth-and-writes
Enter fullscreen mode Exit fullscreen mode

Felt secure. Then I set up CI publishing with an automation token and realized: automation tokens bypass 2FA entirely. By design. There's no human to type the OTP code, so npm just... skips it.

So if someone grabs your NPM_TOKEN from a leaked .env or a compromised GitHub secret, they can publish whatever they want. 2FA doesn't help.

My first automation token had full publish access to every package on my account. Didn't scope it. Didn't restrict IPs. Just a bearer token sitting in a GitHub secret that could publish anything under my name.

# what I should have done from the start
$ npm token create --cidr=<your-ci-ip-range> --publish --package=my-package
Enter fullscreen mode Exit fullscreen mode

Scoped tokens help. But the real fix is OIDC provenance (no long-lived secret at all). The whole token model on npm feels stuck in 2015.

Someone ran npm install in my pnpm repo

I accidentally ran npm install instead of pnpm install in my monorepo. Generated a package-lock.json, committed it without thinking. CI started resolving different dependency versions. Tests went flaky. Took me a full day to trace it back to the lockfile.

My .npmrc now:

frozen-lockfile=true
engine-strict=true
Enter fullscreen mode Exit fullscreen mode

And in the root package.json:

{
  "engines": { "node": ">=22", "pnpm": ">=9" }
}
Enter fullscreen mode Exit fullscreen mode

engine-strict means npm and yarn refuse to install. Sounds aggressive. But pnpm's lockfile stores content-addressable hashes for every tarball. If a dependency gets republished with different contents (yes, this happens, npm allows it within 72 hours), pnpm rejects the mismatch. npm is more forgiving about "fixing" stale lockfiles. That forgiveness is the problem.

I almost shipped a .env

On my third publish I ran npm pack --dry-run out of habit and saw my .env.example in the tarball. It had placeholder values. But the .env file itself was only excluded because .gitignore caught it. If I'd had a .env.local or .env.production that wasn't in .gitignore, it would have shipped to npm.

$ npm pack --dry-run 2>&1
npm notice Tarball Contents
npm notice 1.2kB  README.md
npm notice 15.4kB dist/index.js
npm notice 3.1kB  dist/index.d.ts
npm notice 847B   package.json
npm notice 234B   .env.example     # wait, why is this here?
Enter fullscreen mode Exit fullscreen mode

Without a files field, npm publish ships everything not in .gitignore. I've seen packages on npm that include test fixtures, .git directories (full commit history), even AWS key pairs in a keys/ folder that someone forgot to gitignore.

My fix was adding "files": ["dist", "README.md"] to every package.json. Now only what I explicitly list gets published. npm pack --dry-run before every publish. Takes 5 seconds.

npm audit is noisy

It flags everything. Prototype pollution in a transitive dev dependency that only runs in tests? Critical. ReDoS in a markdown parser you use to render a help page? High.

I ran pnpm audit on my project the first time and got 4 advisories. All in transitive deps. None reachable from my code paths. But each one took 20 minutes to verify because you have to trace the import chain to confirm it's actually dead code.

My process now: pnpm audit weekly, check if the vulnerable path is reachable, update prod deps immediately, batch dev deps monthly.

No Dependabot. Opens too many PRs for 11 direct dependencies. I run pnpm outdated instead and read the changelogs.

$ pnpm outdated
Package     Current  Latest
fastify     5.1.0    5.2.1
drizzle-orm 0.38.2   0.39.0
vitest      3.0.4    3.0.5
Enter fullscreen mode Exit fullscreen mode

Three updates. I'll read the fastify changelog, check if drizzle has breaking changes (it usually does), and bump vitest blindly because test runner patches are low risk.

--ignore-scripts

$ pnpm install --ignore-scripts
$ pnpm rebuild esbuild  # only rebuild what actually needs native compilation
Enter fullscreen mode Exit fullscreen mode

Separates "download dependencies" from "run arbitrary code." Most packages don't need lifecycle scripts. The ones that do (native addons, platform binaries) can be rebuilt explicitly.

I haven't defaulted to this yet because playwright breaks without its postinstall (it downloads browsers). For production CI though, where the dep tree is locked and tested, I'd do it.

The npm ecosystem doesn't have a real security boundary between "install a package" and "run arbitrary code on the user's machine." Provenance is good. Lockfiles help. files field prevents accidental leaks. But if a maintainer's account gets compromised tomorrow, the only thing standing between their users and a malicious postinstall is whether someone notices before the next npm install.

I have eleven dependencies. I can audit them manually. If you have two hundred, I don't know what to tell you.

Part of ClawNexus, an open-source identity registry for AI agents.

Top comments (0)