Supply-Chain Hardening for TypeScript Libraries: Everything We Did and Why
A maintainer's field guide to the supply-chain posture we shipped for DaloyJS — .npmrc that says no by default, pnpm 11 workspace keys (blockExoticSubdeps / strictDepBuilds / verifyDepsBeforeRun), SHA-pinned actions, permissions: {}, no Actions cache on installs, zizmor + Scorecard + CodeQL, npm trusted publishing with provenance, and the create-daloy --with-ci bundle that drops the app-safe parts into your project.
Hi, Devlin. Ten years of fullstack, currently in Norway, currently wishing I could un-read the changelogs of three different npm worm campaigns. The 2025 and 2026 supply-chain news has been rough — chalk/debug, node-ipc, Shai-Hulud, TanStack — and if you maintain a TypeScript library that other people install, you probably had the same thought I did: this could've been me, and I'm not actually sure my defaults would've saved me.
So this post is the maintainer-facing writeup of every supply-chain control we shipped for DaloyJS, plus the create-daloy --with-ci flag that drops the app-safe pieces into a brand-new user project. Nothing here is DaloyJS-specific — these are reusable defaults for pnpm-based TypeScript projects in 2026. Steal what you need.
The mental model: deny by default, opt in deliberately
Every control below is a variant of the same trick: take a permissive default that the ecosystem ships, flip it to deny, and add a small allowlist for the legitimate cases. Lifecycle scripts go from "run all of them silently" to none of them, except esbuild. GitHub Actions permissions go from everything to nothing, except contents: read on these specific jobs. NPM tokens go from long-lived, attached to a human account to none, ever, the runner does an OIDC exchange at publish time. The pattern repeats. Once you internalize it, the config writes itself.
Layer 1: .npmrc, the gate everything passes through
This file runs on every contributor's laptop and on every CI run. If you only fix one file in your repo, fix this one.
Three lines do most of the work. ignore-scripts=true stops every transitive postinstall hook — the canonical execution channel for the recent worm campaigns. frozen-lockfile=truemakes a tampered lockfile cause an install failure, not a silent "sure, let me grab a different version". minimum-release-age=1440says "don't install anything published in the last 24 hours", which is the single most effective filter against worm campaigns because they are typically detected and unpublished within hours.
Layer 2: pnpm 11 workspace keys
pnpm 11 added a set of workspace-level keys that complement .npmrc and let you encode supply-chain intent at the workspace boundary, not the per-process boundary. We use all of them.
A transitive dep specified as a git URL or tarball. That's how a hijacked maintainer's GitHub fork has been smuggled into apps before — the direct dep on npm looks clean, the transitive one resolves to a git fork the attacker controls.
Approximately zero. If you genuinely need a git dep, declare it directly. Indirect git deps are almost never intentional.
Any dep with an unreviewed install script. Combined with allowBuilds: { esbuild: true }, every other build-time script in the dep graph fails the install loud and proud.
The first time you add a new dep with a postinstall, you have to add it to allowBuilds. That's a feature.
A stale node_modules persisting across a malicious PR being merged and reverted. Every pnpm run / pnpm exec re-validates the install state first.
A handful of ms per script invocation. You will not notice.
Layer 3: GitHub Actions — three rules that matter
Most of the Actions security advice on the internet is some variant of be careful, which is not advice. Three rules are concrete:
- Top-level
permissions: {}. Every workflow starts with zero scopes. Each job opts in to the minimum it needs.id-token: writein particular is granted on the publish job only — it's the credential the TanStack attackers extracted in 2026-05. - SHA-pin every action. Not
@v4, not@main, the full 40-character commit SHA. The comment after it (# v4) is for humans. Dependabot keeps the SHAs updated. - No
cache: pnpmon the install step. The GitHub Actions cache has been used as both an exfiltration channel and a persistence channel. Cold installs in CI cost ~30s. Pay them.
The full release workflow is what those three rules look like in practice:
Layer 4: static analysis on the workflows themselves
You can write the most carefully locked-down workflow on earth and someone will paste a snippet from a blog post and re-introduce contents: write on a PR-triggered job. The fix is to lint your workflows, the way you lint your code. zizmoris the tool I've been pleased with: it catches missing permissions, unpinned actions, dangerous pull_request_target usage, and a long list of paper-cut security smells.
Layer 5: continuous scoring — Scorecard + CodeQL
OpenSSF Scorecardgives you a weekly numeric score of your security posture across ~18 checks (signed releases, branch protection, dependency update tools, etc). It's not perfect; it's a useful trend line. CodeQLis GitHub's built-in static analysis for TS/JS. Both upload SARIF to the same code-scanning UI, which keeps the noise in one place.
Layer 6: trusted publishing + provenance — bye, npm tokens
For most of npm's history, publishing meant NODE_AUTH_TOKEN sitting in GitHub Actions secrets. That token is the keys to the kingdom: anyone with it can publish anything to your package. When it leaks — and tokens leak — the attacker has minutes before anyone notices.
Trusted publishing is the fix. Your npm account configures a trust policy that says "this exact GitHub repo, this exact workflow, this exact environment". At publish time the runner does an OIDC exchange and gets a one-shot, short-lived credential. You delete all long-lived npm tokens. They cannot leak if they do not exist.
--provenance is the companion: every published tarball gets a Sigstore attestation that records the exact commit SHA, workflow file, and runner that produced it. Consumers can verify that an install is from the source you claim it is. (npm verifies provenance automatically on install for packages that publish it.)
Layer 7: lockfile source verification
One last paranoid layer. pnpm-lock.yaml can record a non-npm registry, a git URL, or a raw tarball URL for any resolution. A malicious PR can change a single resolution and the install will silently succeed. This script catches that:
It's 20 lines and it has caught a real PR mistake (not malicious — a contributor pasted a tarball URL into a packageManager override). Worth the 20 lines.
The shortcut: create-daloy --with-ci
All of the above is reusable, and reusable should mean one command, you have it. So we wired it into the scaffolder:
--with-ci defaults to yes. The scaffolded project starts with the application-safe posture this post describes: the .npmrc, the pnpm-workspace.yaml keys, every workflow SHA-pinned with permissions: {}, CODEOWNERS, Dependabot, SECURITY.md, and verify-lockfile-sources.mjs as a pnpm verify:lockfilescript. You don't opt into security; you opt out of it (with --no-ci) if you insist. It does not generate an npm publish workflow, because a scaffolded Daloy app is a service, not a library release train.
The attack-path map, in one screen
This is the cheat sheet I keep open when I'm reviewing a new repo's security posture. Each row is an attack class. Whichever rows on the right are missing, that's your work list.
What this doesn't protect you from
Honest section. Supply-chain hardening protects against install-time and build-timecompromise. It does nothing for runtime vulnerabilities in your own code — write tests, run CodeQL, treat input as untrusted. It does nothing for a maintainer's laptop being compromised — use a hardware key, separate publish identities, and read the audit log of your npm account every so often. And it does nothing for the case where your upstream language ecosystem ships a bad release — the minimum-release-agecooldown helps with that, but isn't a guarantee. Layered defenses, applied where the cost is reasonable.
Honest section, part two: I have absolutely shipped a supply-chain footgun. Not recently, but it happened. The version of this post I wish I'd read five years ago is the one I tried to write here. I hope it lands for at least one other maintainer who opens their .npmrc today and finds ignore-scripts isn't there.
Steal the config
Every file in this post is open-source in the DaloyJS repo and comes with comments that explain why, not just what. The best place to start is .npmrc + pnpm-workspace.yaml; the next best place is to copy .github/workflows/release.yml and adapt the package name if you are publishing a library. For an app, run pnpm create daloy@latest --with-ci and cherry-pick from the generated CI and deploy starters.
The full discussion of the trade-offs is in the supply-chain docs, and the broader security overview shows how this slots in with sessions, CSRF, and CSP.
Thanks for reading. Now go grep your .github/workflows for @vand replace each one with the SHA. I'll wait. (It's tedious for ten minutes and then you're done, forever, until Dependabot does it for you.)
— Devlin