|
| 1 | +# Securing your npm installs |
| 2 | + |
| 3 | +A practical guide for consumers of `@supabase/supabase-js` (and any other npm package) to defend against npm supply-chain attacks. |
| 4 | + |
| 5 | +> Looking to **report** a vulnerability in Supabase itself? See [SECURITY.md](./SECURITY.md) instead. This guide is about hardening your install of Supabase (and other) packages on your machines and in your CI. |
| 6 | +
|
| 7 | +## Why this guide exists |
| 8 | + |
| 9 | +The same attack pattern keeps recurring: a popular npm package is compromised, the new version executes attacker code on `npm install` via a lifecycle script or transitive dependency, the malware harvests credentials from the install host, and then self-propagates by republishing other packages the victim maintains. |
| 10 | + |
| 11 | +The good news: most of the impact is preventable from the consumer side, regardless of what any one publisher does. This guide is the set of settings and habits we recommend you adopt. |
| 12 | + |
| 13 | +## TL;DR — what to do today |
| 14 | + |
| 15 | +1. **Commit your lockfile** and install with `--frozen-lockfile` (pnpm/yarn) or `npm ci` (npm) in CI. |
| 16 | +2. **Quarantine new versions.** Set a minimum release age (≥ 7 days) so installs won't pick up a brand-new version while attackers are still in their detection window. npm, pnpm, yarn, and bun all support this natively now. |
| 17 | +3. **Block exotic transitive deps.** Refuse `github:`, `git+`, and `file:` refs that didn't come from the npm registry. |
| 18 | +4. **Verify provenance.** Run `npm audit signatures` after install. Supabase packages publish with sigstore attestations. |
| 19 | +5. **Constrain lifecycle scripts.** Default-deny `postinstall` / `preinstall` / `prepare`; allow per-package. |
| 20 | +6. **Pin a single source of truth for your package manager** (`packageManager` field in `package.json` with a sha512 hash). |
| 21 | +7. **Have a rollback plan.** Know which packages, which versions, and which credentials to rotate if a compromise is announced upstream. |
| 22 | + |
| 23 | +The rest of this document expands on each of these and covers the Edge Functions / Deno case separately. |
| 24 | + |
| 25 | +## Pin your dependency versions |
| 26 | + |
| 27 | +A committed lockfile is the floor, not the ceiling. |
| 28 | + |
| 29 | +**Application repos**: |
| 30 | + |
| 31 | +- Commit `package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`, or `bun.lock`. |
| 32 | +- In CI, install with `npm ci` / `pnpm install --frozen-lockfile` / `yarn install --immutable` / `bun install --frozen-lockfile`. These fail if `package.json` and the lockfile disagree, which is what you want. |
| 33 | +- Caret ranges (`^1.2.3`) in `package.json` are fine **if** you also have a lockfile and the rest of the guidance below — the lockfile is what actually gets installed. |
| 34 | + |
| 35 | +**Pin transitive risk with overrides.** If you don't trust a particular transitive dep version, force a known-good version via: |
| 36 | + |
| 37 | +```jsonc |
| 38 | +// npm and pnpm |
| 39 | +"overrides": { |
| 40 | + "some-dep": "1.2.3" |
| 41 | +} |
| 42 | + |
| 43 | +// yarn |
| 44 | +"resolutions": { |
| 45 | + "some-dep": "1.2.3" |
| 46 | +} |
| 47 | +``` |
| 48 | + |
| 49 | +This is the lever to reach for when you see a CVE on a transitive you don't directly depend on. |
| 50 | + |
| 51 | +## Quarantine new versions (minimum release age) |
| 52 | + |
| 53 | +Most npm compromises are detected and remediated within hours. A short quarantine on freshly-published versions is the single highest-leverage setting. |
| 54 | + |
| 55 | +### pnpm (recommended) |
| 56 | + |
| 57 | +pnpm v11 turns this on by default (1440 minutes = 1 day). You can raise it. In `pnpm-workspace.yaml` at the repo root (pnpm 10+ reads config from this file with or without workspaces): |
| 58 | + |
| 59 | +```yaml |
| 60 | +minimumReleaseAge: 10080 # 7 days, in minutes |
| 61 | +minimumReleaseAgeExclude: |
| 62 | + - '@your-org/*' # bypass for your own internal packages |
| 63 | +``` |
| 64 | +
|
| 65 | +Set `minimumReleaseAge: 0` only if you have a specific reason to opt out of the default. |
| 66 | + |
| 67 | +#### `trustPolicy` (pnpm) |
| 68 | + |
| 69 | +Independent of the age gate, pnpm's `trustPolicy: no-downgrade` refuses to install a version whose trust level (trusted publisher → provenance → none) has dropped relative to previous releases of the same package. That catches the case where an attacker can publish but can't replicate the original maintainer's OIDC binding: |
| 70 | + |
| 71 | +```yaml |
| 72 | +trustPolicy: no-downgrade |
| 73 | +trustPolicyExclude: |
| 74 | + - 'some-package' # opt specific packages out if needed |
| 75 | +trustPolicyIgnoreAfter: '180d' # ignore checks for packages older than 180 days |
| 76 | +``` |
| 77 | + |
| 78 | +### yarn (Berry / v4+) |
| 79 | + |
| 80 | +In `.yarnrc.yml`: |
| 81 | + |
| 82 | +```yaml |
| 83 | +npmMinimalAgeGate: '7d' |
| 84 | +npmPreapprovedPackages: # opt specific packages out of all package gates |
| 85 | + - '@your-org/*' |
| 86 | +``` |
| 87 | + |
| 88 | +Versions newer than the gate are excluded from resolution. Yarn's docs also note this guards against the npm registry's 72-hour unpublish window — a package you just installed could vanish, breaking your build, if you don't wait it out. |
| 89 | + |
| 90 | +Two related yarn settings worth knowing about while you're in `.yarnrc.yml`: |
| 91 | + |
| 92 | +- **`enableScripts: false`** is the default in yarn — postinstall scripts from third-party packages don't run. Workspaces still run their own. |
| 93 | +- **`enableHardenedMode: true`** makes yarn re-query remote registries to confirm that the lockfile content matches what the registry currently serves. Auto-on for GitHub PRs from public repos; worth turning on permanently if your threat model warrants slower installs. |
| 94 | + |
| 95 | +### npm |
| 96 | + |
| 97 | +Use the [`min-release-age`](https://docs.npmjs.com/cli/configuring-npm/config#min-release-age) config (relative, in days) or [`before`](https://docs.npmjs.com/cli/configuring-npm/config#before) (absolute date). Set in `.npmrc`: |
| 98 | + |
| 99 | +```ini |
| 100 | +min-release-age=7 |
| 101 | +``` |
| 102 | + |
| 103 | +Or per-command: |
| 104 | + |
| 105 | +```bash |
| 106 | +npm install --min-release-age=7 |
| 107 | +``` |
| 108 | + |
| 109 | +If `min-release-age` isn't available in your npm version yet, fall back to a private mirror or to a CI gate that calls `npm view <pkg>@<version> time.<version>` and rejects installs whose newest version was published in the last N days. |
| 110 | + |
| 111 | +### Bun |
| 112 | + |
| 113 | +Use the `--minimum-release-age` flag (seconds), or set it once in `bunfig.toml`: |
| 114 | + |
| 115 | +```toml |
| 116 | +[install] |
| 117 | +minimumReleaseAge = 604800 # 7 days, in seconds |
| 118 | +minimumReleaseAgeExcludes = ["@types/node", "typescript"] # trusted bypass |
| 119 | +``` |
| 120 | + |
| 121 | +Or per-command: |
| 122 | + |
| 123 | +```bash |
| 124 | +bun add @supabase/supabase-js --minimum-release-age 604800 |
| 125 | +``` |
| 126 | + |
| 127 | +Bun's age gate only affects new resolutions — existing entries in `bun.lock` are unchanged. It also runs a stability check: if multiple versions were published close together just outside your gate, Bun extends the filter to skip those (likely unstable) versions and picks an older, more mature one. Exact-version requests (`pkg@1.1.1`) respect the gate but bypass the stability extension. |
| 128 | + |
| 129 | +### Deno / Edge Functions |
| 130 | + |
| 131 | +Deno has an unstable `--minimum-dependency-age` flag that accepts minutes, an ISO-8601 duration, or an absolute RFC3339 cutoff: |
| 132 | + |
| 133 | +```bash |
| 134 | +deno install --minimum-dependency-age=P7D # 7-day quarantine |
| 135 | +deno install --minimum-dependency-age=10080 # 7 days, in minutes |
| 136 | +deno install --minimum-dependency-age=2026-04-01 # cut off at a fixed date |
| 137 | +deno install --minimum-dependency-age=0 # disable |
| 138 | +``` |
| 139 | + |
| 140 | +Track Deno's release notes for when this stabilises. See the [Edge Functions](#edge-functions-specifics) section below for the Supabase-runtime-specific picture. |
| 141 | + |
| 142 | +## Use a private registry or mirror |
| 143 | + |
| 144 | +A proxy in front of `registry.npmjs.org` is the strongest layer of defense your org can add. It lets you enforce quarantine, audit, block, and roll back independently of what npmjs.org does. |
| 145 | + |
| 146 | +Common options (we don't endorse any one): [Verdaccio](https://verdaccio.org/), JFrog Artifactory, Sonatype Nexus, AWS CodeArtifact, GitHub Packages. |
| 147 | + |
| 148 | +A minimal `.npmrc` pointing to a private registry: |
| 149 | + |
| 150 | +```ini |
| 151 | +registry=https://npm.your-org.example/ |
| 152 | +@supabase:registry=https://npm.your-org.example/ |
| 153 | +//npm.your-org.example/:_authToken=${NPM_TOKEN} |
| 154 | +always-auth=true |
| 155 | +``` |
| 156 | + |
| 157 | +Notes: |
| 158 | + |
| 159 | +- Never commit the token. Use `${NPM_TOKEN}` from your CI environment (npm 7+ expands env vars in `.npmrc`). |
| 160 | +- Scoped registry lines let you point specific scopes at a different upstream. |
| 161 | +- For a per-project `.npmrc`, put it next to `package.json`. For a per-user one, `~/.npmrc`. |
| 162 | + |
| 163 | +## Verify package provenance |
| 164 | + |
| 165 | +`@supabase/supabase-js`, `@supabase/auth-js`, `@supabase/postgrest-js`, `@supabase/realtime-js`, `@supabase/storage-js`, and `@supabase/functions-js` publish with [sigstore provenance attestations](https://docs.npmjs.com/generating-provenance-statements) via npm OIDC trusted publishing. The attestations cryptographically tie each published tarball to the workflow run, commit, and repository it was built from. |
| 166 | + |
| 167 | +To verify after install: |
| 168 | + |
| 169 | +```bash |
| 170 | +npm audit signatures |
| 171 | +``` |
| 172 | + |
| 173 | +Sample output: |
| 174 | + |
| 175 | +```text |
| 176 | +audited 1 package in 0s |
| 177 | +1 package has a verified registry signature |
| 178 | +``` |
| 179 | + |
| 180 | +A failure here is a strong signal that either your registry mirror is tampered with or the tarball was modified after publish. Use a recent npm CLI (the version bundled with Node.js can lag); install the latest with `npm install -g npm@latest`. |
| 181 | + |
| 182 | +See the existing [README — Verifying provenance attestations](../README.md#-verifying-provenance-attestations) section for additional examples. |
| 183 | + |
| 184 | +## Control lifecycle scripts |
| 185 | + |
| 186 | +`preinstall`, `postinstall`, and `prepare` scripts are the single most common code-execution entry point in a compromised dep. |
| 187 | + |
| 188 | +**pnpm**: declare an allowlist in `pnpm-workspace.yaml`: |
| 189 | + |
| 190 | +```yaml |
| 191 | +allowBuilds: |
| 192 | + esbuild: false |
| 193 | + simple-git-hooks: true |
| 194 | +``` |
| 195 | + |
| 196 | +Default-deny is the goal. Add packages only when you genuinely need their build to run. |
| 197 | + |
| 198 | +**yarn**: `enableScripts: false` is the default — postinstall scripts from third-party packages don't run unless you opt in per package via `dependenciesMeta` in `package.json`. Workspaces still run their own scripts. |
| 199 | + |
| 200 | +**npm / bun**: install with `--ignore-scripts` and only enable scripts for the packages that truly need them. |
| 201 | + |
| 202 | +**The `@supabase/*` core packages run no install/postinstall scripts.** You can safely keep them on the deny list. |
| 203 | + |
| 204 | +## Block exotic dependency references |
| 205 | + |
| 206 | +The TanStack/router attack used `optionalDependencies: { "@tanstack/setup": "github:tanstack/router#<sha>" }` to pull payload code from a fork's git object store, bypassing the npm registry entirely. Block this class of ref: |
| 207 | + |
| 208 | +**pnpm**: in `pnpm-workspace.yaml`: |
| 209 | + |
| 210 | +```yaml |
| 211 | +blockExoticSubdeps: true |
| 212 | +``` |
| 213 | + |
| 214 | +**npm**: the `allow-git`, `allow-remote`, `allow-file`, and `allow-directory` settings each take `"all"` (default), `"none"`, or `"root"`. `"root"` means "only allow this kind of reference if it's declared in your own `package.json`, never as a transitive dep" — which is exactly the trust boundary you want: |
| 215 | + |
| 216 | +```ini |
| 217 | +allow-git=root |
| 218 | +allow-remote=root |
| 219 | +allow-file=root |
| 220 | +allow-directory=root |
| 221 | +``` |
| 222 | + |
| 223 | +**yarn**: use `approvedGitRepositories` to allowlist specific git sources. Anything not matching is rejected: |
| 224 | + |
| 225 | +```yaml |
| 226 | +approvedGitRepositories: |
| 227 | + - 'https://github.com/yarnpkg/*' |
| 228 | + - 'ssh://git@github.com/yarnpkg/*' |
| 229 | +``` |
| 230 | + |
| 231 | +**bun**: no native equivalent today — inspect your `bun.lock` for non-registry refs. |
| 232 | + |
| 233 | +## Pin your package manager |
| 234 | + |
| 235 | +Drift between local dev and CI is a quiet source of risk. Pin the package manager itself with a sha512 hash: |
| 236 | + |
| 237 | +```jsonc |
| 238 | +// package.json |
| 239 | +"packageManager": "pnpm@10.0.0+sha512.<hash>" |
| 240 | +``` |
| 241 | + |
| 242 | +Corepack (bundled with modern Node) and `pnpm/action-setup@v6+` both read this field automatically. A compromised npm mirror serving a tampered pnpm binary fails the hash check instead of running. |
| 243 | + |
| 244 | +## CI / lockfile hygiene |
| 245 | + |
| 246 | +- Run `--frozen-lockfile` / `npm ci` in every CI job. Never let CI silently regenerate the lockfile. |
| 247 | +- Review lockfile diffs in PRs the same way you review code diffs. Unexpected new transitives or version jumps deserve a question. |
| 248 | +- Configure Dependabot or Renovate to batch updates and respect the same min-age you set locally. Renovate's `minimumReleaseAge` option is the direct equivalent. |
| 249 | +- Run `npm audit signatures` as a non-blocking CI step so a tampered tarball is caught early. |
| 250 | + |
| 251 | +## Edge Functions specifics |
| 252 | + |
| 253 | +If you're using `@supabase/supabase-js` (or any `npm:` specifier) from Deno in a Supabase Edge Function, the picture is slightly different. |
| 254 | + |
| 255 | +Deno's `--minimum-dependency-age` (see above) is currently behind an unstable flag, and the Supabase Edge Runtime upgrade path for Deno is being worked on separately. Until your runtime supports it, the practical defenses are: |
| 256 | + |
| 257 | +- **Pin to exact versions** in your import map / `deno.json` — avoid floating tags like `latest`. |
| 258 | +- **Vendor critical dependencies** (`deno vendor`) and commit the vendored output. This freezes the dep at a known-good snapshot and removes the runtime fetch entirely. |
| 259 | +- **Use `--lock` and `--lock-write`** in CI to fail any build that pulls in unexpected content. |
| 260 | +- **Stay current on Deno** — newer versions are landing more supply-chain features (lockfile integrity, `npm:` provenance verification, min-age stabilisation). Track the [Deno release notes](https://github.com/denoland/deno/releases). |
| 261 | + |
| 262 | +Talk to the Supabase Functions team if your security posture depends on a feature only in a newer Deno. |
| 263 | + |
| 264 | +## If you suspect you installed a compromised version |
| 265 | + |
| 266 | +Move quickly. Order of operations: |
| 267 | + |
| 268 | +1. **Treat the install host as potentially compromised.** Anything readable by the user that ran the install — env vars, files, secrets in memory — should be assumed exfiltrated. |
| 269 | +2. **Rotate credentials reachable from that host**: cloud provider keys (AWS, GCP, Azure), Kubernetes / Vault tokens, GitHub tokens, npm tokens, SSH keys, and any Supabase service-role keys or anon keys that touched the box. |
| 270 | +3. **Wipe `node_modules`** and your package manager cache (`npm cache clean --force`, `pnpm store prune`, `yarn cache clean`). |
| 271 | +4. **Pin to a known-good version** in `package.json` and reinstall against a fresh cache. |
| 272 | +5. **Check `npm audit` and the [GitHub Advisory Database](https://github.com/advisories)** for the package. |
| 273 | +6. **Report it**: file a GitHub Security Advisory on the upstream repo, and email `security@npmjs.com` if the version is still installable. |
| 274 | + |
| 275 | +## What Supabase does on its side |
| 276 | + |
| 277 | +You shouldn't have to trust us — that's the whole point of this guide — but for completeness: |
| 278 | + |
| 279 | +- **OIDC trusted publishing.** No long-lived `NPM_TOKEN` secret. Each publish is authenticated against npm using a short-lived OIDC token bound to the release workflow. |
| 280 | +- **Provenance attestations.** Every release of `@supabase/supabase-js` and its sibling packages ships with a sigstore attestation tying the tarball to its source commit and workflow run. Verify with `npm audit signatures`. |
| 281 | +- **No `postinstall` / `preinstall` scripts** in any of the six core packages (`auth-js`, `postgrest-js`, `realtime-js`, `storage-js`, `functions-js`, `supabase-js`). You can safely install with `--ignore-scripts`. |
| 282 | +- **Fixed-version monorepo releases.** All packages release together with identical versions, so when you pin one, you pin them all. |
| 283 | + |
| 284 | +If something looks wrong with a published `@supabase/*` package, report it via the channels in [SECURITY.md](./SECURITY.md). |
| 285 | + |
| 286 | +## References |
| 287 | + |
| 288 | +- TanStack/router compromise postmortem: [TanStack/router#7383](https://github.com/TanStack/router/issues/7383), [GHSA-g7cv-rxg3-hmpx](https://github.com/TanStack/router/security/advisories/GHSA-g7cv-rxg3-hmpx). |
| 289 | +- Adnan Khan, ["The Monsters in Your Build Cache — GitHub Actions Cache Poisoning"](https://adnanthekhan.com/2024/05/06/the-monsters-in-your-build-cache-github-actions-cache-poisoning/) (May 2024). |
| 290 | +- GitHub Security Lab, ["Keeping your GitHub Actions and workflows secure: Preventing pwn requests"](https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/) (Part 1 of a 4-part series, 2021–2025). |
| 291 | +- npm trusted publishing: <https://docs.npmjs.com/trusted-publishers>. |
| 292 | +- npm provenance: <https://docs.npmjs.com/generating-provenance-statements>. |
| 293 | +- pnpm `minimumReleaseAge`, `blockExoticSubdeps`, `allowBuilds`: <https://pnpm.io/settings>. |
| 294 | +- Renovate `minimumReleaseAge`: <https://docs.renovatebot.com/configuration-options/#minimumreleaseage>. |
| 295 | +- GitHub Advisory Database: <https://github.com/advisories>. |
0 commit comments