+1 (415) 779-8456

Hardening a Gatsby Build Against npm Supply-Chain Attacks

A Gatsby site is a build-time artifact, which is usually a security advantage: there is no runtime server to exploit, no database to inject into, and the thing you deploy is a folder of HTML. But that advantage moves the risk somewhere else. Everything an attacker could want happens during gatsby build — hundreds of transitive npm packages execute lifecycle scripts, plugins run arbitrary Node with your CMS tokens in process.env, and the output is written straight to a CDN that your visitors trust.

2025 made this concrete. The compromise of widely used packages such as chalk and debug in September, followed by the self-replicating "Shai-Hulud" worm that harvested npm and cloud credentials from CI environments, hit projects that had done nothing wrong except run npm install on the wrong afternoon. Gatsby sites are squarely in the blast radius: a typical Gatsby 5 dependency tree resolves well over a thousand packages, many of them unmaintained plugins pinned with caret ranges.

This tutorial is the hardening pass we run on client Gatsby repos. None of it requires leaving Gatsby, and most of it is an afternoon of work.

1. Know what you actually install

Start by measuring the surface area:

npm ls --all --parseable | wc -l
npm ls --all --parseable | sed 's|.*node_modules/||' | sort -u | wc -l

The second number is your real dependency count. Then find which of those packages run code at install time — that is where drive-by compromises land:

# packages with install/postinstall scripts
npm query ":attr(scripts, [postinstall])" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{JSON.parse(s).forEach(p=>console.log(p.name+'@'+p.version))})"

On a mature Gatsby site this usually returns sharp, esbuild, maybe lmdb, core-js, and a handful of analytics or image plugins. Anything on that list you do not recognise deserves a look before your next deploy, not after.

2. Install with a lockfile, and only a lockfile

CI must never be allowed to resolve a fresh version range. The difference matters:

npm ci            # installs exactly what package-lock.json says; fails if out of sync
npm install       # may resolve new minor/patch versions and rewrite the lock

Use npm ci in every CI job. If you are on pnpm, pnpm install --frozen-lockfile; on Yarn Berry, yarn install --immutable. Commit the lockfile, and treat an unexplained lockfile diff in a PR the way you would treat an unexplained change to gatsby-node.js.

Add a cooldown so you never install a package version that is minutes old — the malicious releases in 2025 were typically yanked within hours, and a short delay avoids most of them without any human judgement:

# .npmrc
ignore-scripts=false
audit-level=high
fund=false
# npm 11.6+: refuse versions published less than 3 days ago
before=
minimumReleaseAge=4320

If your npm version does not support minimumReleaseAge, pnpm 10.16+ offers minimumReleaseAge in pnpm-workspace.yaml, and Renovate/Dependabot can enforce the same with a minimumReleaseAge / stabilityDays setting on the update PRs.

3. Turn off lifecycle scripts, then allow the few you need

The strongest single control is refusing to execute install scripts by default. With npm:

npm ci --ignore-scripts

Gatsby will then fail on native builds — sharp needs its prebuilt binary, lmdb and esbuild need theirs. Rather than reverting, allow-list them. pnpm makes this first-class:

# pnpm-workspace.yaml
onlyBuiltDependencies:
  - sharp
  - esbuild
  - lmdb

On npm, the pragmatic equivalent is --ignore-scripts plus an explicit rebuild step:

npm ci --ignore-scripts
npm rebuild sharp esbuild lmdb

Verify the build still produces images after this change — gatsby-plugin-image failing silently to a broken sharp install is a classic. ls public/static/*.webp | head after a build is enough of a smoke test.

4. Prefer packages with provenance

npm now supports build provenance: a signed attestation linking a published tarball to the exact GitHub Actions run and commit that produced it. Trusted publishing (OIDC, no long-lived npm tokens) is what stopped several 2025 token-theft attacks from spreading further.

Check your own tree:

npm audit signatures

This verifies registry signatures and reports how many of your dependencies ship attestations. You will not get to 100% — most of the Gatsby plugin ecosystem predates provenance — but the report is a useful input when you are choosing between two equivalent plugins, and it catches tarballs that do not match the registry's signature at all.

If you publish anything yourself (an internal Gatsby theme, a shared component library), publish it with provenance from CI:

# .github/workflows/publish.yml
permissions:
  contents: read
  id-token: write
steps:
  - uses: actions/setup-node@v4
    with:
      node-version: 22
      registry-url: https://registry.npmjs.org
  - run: npm ci --ignore-scripts
  - run: npm publish --provenance --access public

5. Give the build the smallest possible set of secrets

Gatsby builds are noisy environments: CMS read tokens, analytics keys, sometimes an API key with more scope than anyone intended. A compromised transitive dependency reads process.env and exfiltrates all of it. Reduce what is there to steal:

  • Read-only tokens only. A Contentful/Sanity/WordPress token used by gatsby-source-* never needs write scope.
  • Split preview from production. The preview/draft token belongs to the preview build job, not the production one.
  • No cloud credentials in the build job. Deploy in a separate job that runs after the build and has no node_modules of its own beyond the deploy CLI.
  • Scope GATSBY_ variables deliberately. Anything prefixed GATSBY_ is inlined into client JavaScript and is public by definition. Grep before you ship:
grep -ro "GATSBY_[A-Z0-9_]*" src gatsby-*.js | sort -u

In GitHub Actions, be explicit about token scope on the build job:

jobs:
  build:
    permissions:
      contents: read      # no packages:write, no id-token
    steps:
      - uses: actions/checkout@v4
        with: { persist-credentials: false }
      - run: npm ci --ignore-scripts
      - run: npm rebuild sharp esbuild lmdb
      - run: npx gatsby build

persist-credentials: false matters: the default leaves a usable git credential in the runner's config, which is exactly what the Shai-Hulud worm looked for.

6. Watch outbound network traffic during the build

A static build has a predictable network profile: the registry during install, your CMS and image sources during gatsby build, nothing else. That predictability is worth enforcing. On a self-hosted or container runner you can block by default and allow-list:

# build container: no shell, no curl, pinned Node
FROM node:22-slim
RUN useradd -m build
USER build

Combined with an egress policy (GitHub's step-security/harden-runner, a Docker network with an explicit allow-list, or your CI provider's egress rules), you get an alert when a postinstall script tries to reach an unfamiliar host. In practice this is the control that catches novel attacks, because it does not depend on knowing the bad package's name.

7. Audit on a schedule, not on every PR

npm audit on every pull request trains everyone to ignore it — a Gatsby 4 or 5 tree will report dozens of advisories in dev-only transitive dependencies that cannot be reached at runtime because there is no runtime. Instead:

# .github/workflows/audit.yml
on:
  schedule: [{ cron: '0 6 * * 1' }]
jobs:
  audit:
    steps:
      - uses: actions/checkout@v4
      - run: npm ci --ignore-scripts
      - run: npm audit --audit-level=high --omit=dev || true
      - run: npm audit signatures

Run it weekly, triage it as a batch, and record decisions. For a static site the triage question is nearly always: can this code execute during the build, or does it ship to the browser? If neither, it is noise. Write that judgement down in the PR so the next person does not redo it.

8. Make the artifact verifiable

Finally, know what you deployed. Generate an SBOM and keep it with the build:

npm sbom --sbom-format cyclonedx > artifacts/sbom.json

If a package is disclosed as compromised next month, an SBOM per deploy turns "were we affected?" from a two-day archaeology project into a grep. Pair it with a manifest of the output:

find public -type f -exec sha256sum {} + | sort -k2 > artifacts/public.sha256

Two builds from the same commit and the same lockfile should produce nearly identical manifests. Diffs that appear without a content change are worth explaining before they reach the CDN.

The short version

If you only do three things this quarter:

  1. npm ci --ignore-scripts plus an explicit npm rebuild allow-list in CI.
  2. A release-age cooldown on dependency updates, so you never install a package version published hours ago.
  3. Strip the build job down to read-only CMS tokens and contents: read, and deploy from a separate job.

Those three remove most of the practical risk in a Gatsby pipeline without changing a line of application code. The rest — provenance checks, egress policy, SBOMs — is what turns an incident from an outage into an afternoon of verification.

StaticCraft's Gatsby developers do this hardening pass as part of maintenance and rescue engagements. If you want a second pair of eyes on a build pipeline you inherited, get in touch.