+1 (726) 227-3745

Locking Down the npm Supply Chain in a MEAN App: Node 24 Permissions, Provenance and Trusted Publishing

Most of the MEAN security work we get called into is still application-level: an unvalidated query object, a missing auth guard. But the incidents that actually took client teams offline in the last year did not come through the application at all. They came through npm install. The chalk/debug compromise and the self-replicating "Shai-Hulud" worm that followed both worked the same way: a maintainer token was phished, a patch version was published with a malicious lifecycle script, and every CI job that ran a fresh install within the next few hours executed it.

A MEAN app is a big target for this, because it has three separate install surfaces: the Angular workspace, the Express API, and the container build that installs both. This tutorial is the hardening pass we run on client repositories. It takes an afternoon and does not require changing a line of application code.

What we are defending against

Three distinct attacks, which need three different controls:

  1. A malicious version published to a package you already use. Defence: install from a pinned lockfile, never a floating range.
  2. Code that runs at install time. Defence: do not execute lifecycle scripts you have not reviewed.
  3. Code that runs at runtime and reaches for credentials, ~/.npmrc, or the network. Defence: the Node 24 permission model.

Step 1: make every install reproducible

If your CI job runs npm install, it is allowed to resolve a newer version than the one you tested. Use npm ci, which fails rather than updating package-lock.json:

cd api && npm ci
cd ../web && npm ci

Then make that the only supported path. In both package.json files:

{
  "engines": { "node": ">=24.0.0 <25", "npm": ">=11" },
  "scripts": {
    "preinstall": "npx only-allow npm"
  }
}

Commit the lockfiles for both workspaces. A surprising number of MEAN repos we audit have package-lock.json in .gitignore for the Angular side because someone hit a merge conflict in 2019.

Step 2: stop install scripts from running

This is the single highest-value change. The worm needed postinstall to execute. Turn lifecycle scripts off globally and re-enable them only for the handful of packages that genuinely need to build a binary.

Create .npmrc at the repository root:

ignore-scripts=true
audit-level=high
fund=false
save-exact=true

Now find out what actually broke. In a MEAN project the usual list is short — native or binary-fetching packages such as esbuild, @parcel/watcher, sharp, bcrypt, or a Playwright browser download. Use npm 11's allow-list rather than flipping scripts back on:

{
  "onlyBuiltDependencies": [
    "esbuild",
    "sharp"
  ]
}

A useful follow-up: replace bcrypt (native, compiles at install) with argon2 or the pure-JS bcryptjs, and prefer mongodb/mongoose builds that need no compilation. Every native dependency you remove is one fewer script you have to trust.

Verify nothing runs:

rm -rf node_modules
npm ci --foreground-scripts   # watch the output; you should see only allow-listed builds

Step 3: refuse brand-new versions

Almost every one of these compromises is caught and unpublished within hours. A short cooling-off period on new releases removes most of the exposure at essentially zero cost. npm 11 supports a minimum release age:

# .npmrc
minimum-release-age=1440   # minutes; refuse anything published in the last 24h

If your tooling does not support that flag yet, get the same effect by pinning exact versions (save-exact=true above) and letting Dependabot or Renovate open upgrade PRs on a schedule with a minimumReleaseAge of a few days. The important property is that no human urgently typing npm install express@latest is the thing standing between you and a bad publish.

Step 4: check provenance before you trust a package

npm now records signed provenance attestations for packages published from CI with trusted publishing, which ties a tarball to a specific repository and workflow run. You can verify the whole tree:

npm audit signatures

That command validates registry signatures and reports how many of your dependencies have verified provenance. For an individual candidate dependency:

npm view some-package dist.attestations
npm view some-package repository.url maintainers

Use this as a selection criterion during code review, not as a hard gate — provenance coverage across the registry is good and improving, but it is not universal, so a blanket "fail if unattested" rule will simply be switched off by the first developer it inconveniences.

If you publish your own internal packages, this is also the cue to move them to trusted publishing (OIDC from your CI provider) and delete the long-lived automation tokens. A token that does not exist cannot be phished.

Step 5: a CI gate that is actually enforceable

Add a job that runs on every pull request. Keep it fast and keep it boring, so nobody routes around it:

# .github/workflows/supply-chain.yml
name: supply-chain
on: [pull_request]

permissions:
  contents: read

jobs:
  audit:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        dir: [api, web]
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v5
        with:
          node-version: 24
          cache: npm
          cache-dependency-path: ${{ matrix.dir }}/package-lock.json
      - name: Install without scripts
        run: npm ci --ignore-scripts
        working-directory: ${{ matrix.dir }}
      - name: Fail on high/critical advisories
        run: npm audit --audit-level=high
        working-directory: ${{ matrix.dir }}
      - name: Verify registry signatures
        run: npm audit signatures
        working-directory: ${{ matrix.dir }}
      - name: Lockfile must not drift
        run: git diff --exit-code -- package-lock.json
        working-directory: ${{ matrix.dir }}

Two details matter more than the audit itself. permissions: contents: read means a compromised dependency running inside this job has no write token to steal. And the git diff --exit-code step catches the case where someone's install quietly rewrote the lockfile.

Step 6: run the API under the Node 24 permission model

Even with all of the above, a dependency you legitimately installed can misbehave at runtime. Node 24's permission model, now stable, lets you deny filesystem and child-process access by default and grant back only what the API needs. For a typical Express 5 service:

node --permission \
  --allow-fs-read=./dist \
  --allow-fs-read=./package.json \
  --allow-fs-write=/tmp/uploads \
  dist/server.js

With --permission on, child_process, worker_threads and native addons are blocked unless explicitly allowed, and any read outside the granted paths throws ERR_ACCESS_DENIED. A credential stealer that tries to open ~/.npmrc, ~/.aws/credentials or /proc/self/environ fails loudly instead of exfiltrating.

Expect a shakeout period. Things that commonly need a grant in a MEAN API:

  • Certificate bundles for MongoDB Atlas TLS — add --allow-fs-read=/etc/ssl/certs.
  • Sharp / image processing — needs its native binding; grant --allow-addons or move that work to a separate unprivileged service.
  • Source maps and .env files — grant the specific file, not the directory.

Bake the flags into the container so nobody forgets them locally:

FROM node:24-alpine
WORKDIR /app
COPY --chown=node:node package*.json ./
RUN npm ci --omit=dev --ignore-scripts
COPY --chown=node:node dist ./dist
USER node
ENV NODE_OPTIONS="--permission --allow-fs-read=/app --allow-fs-write=/tmp"
CMD ["node", "dist/server.js"]

Note that the permission model is process-level, not a sandbox: it complements a non-root user and a read-only root filesystem, it does not replace them.

Step 7: keep a bill of materials

When the next advisory lands, the question is "were we exposed, and in which release?" Generate an SBOM as a build artefact so you can answer it in minutes:

npm sbom --sbom-format cyclonedx --omit dev > sbom.json

Store it alongside the image tag. Combined with pinned lockfiles, you can say exactly which versions shipped on which day — which is also what enterprise security reviews and SOC 2 auditors ask for.

The checklist

  • npm ci everywhere; lockfiles committed for both API and Angular workspaces.
  • ignore-scripts=true, with a short onlyBuiltDependencies allow-list.
  • save-exact=true plus a release-age delay or scheduled bot upgrades.
  • npm audit signatures in CI; trusted publishing for anything you publish.
  • CI jobs with read-only tokens and a lockfile-drift check.
  • Express 5 API started with --permission and explicit --allow-fs-* grants.
  • An SBOM per release.

None of this is exotic, and none of it requires touching your Angular components or Mongoose models. It does require someone to own it — which, in most of the teams we work with, is the part that has been missing.

If you want a second pair of eyes on a MEAN codebase before an audit or after an advisory, get in touch and we will walk your dependency tree and CI configuration with you.