DEV Community

Cody Pack
Cody Pack

Posted on

I Publish npm Packages With No Tokens Stored Anywhere — Here's the Setup

Last week I shipped version 0.3.0 of SolAudit, a static analyzer for Solana programs. The publish step involved zero secrets. No npm token in GitHub Actions secrets. No token on my laptop. No token anywhere. And anyone can cryptographically verify that the tarball on npm came from a specific commit in my repo, built by a specific workflow run.

This is npm's trusted publishing (OIDC) plus Sigstore provenance, and it took me about 30 minutes to set up. This post is the full walkthrough, including the two gotchas that cost me a failed run each.

The problem with npm tokens

The classic way to publish from CI is:

  1. npm token create on your machine
  2. Paste it into GitHub repo secrets as NPM_TOKEN
  3. Reference it in a workflow

That token is a long-lived credential with full publish rights to your package. If it leaks — a compromised dependency, a malicious Action, a rogue postinstall script in your CI environment — someone else publishes as you, and every npm install your-package pulls their code.

This isn't hypothetical. Token theft and malicious version bumps are how several real npm supply-chain incidents have worked. The ecosystem's answer is to eliminate the standing credential entirely.

What trusted publishing actually does

Instead of a stored token, the GitHub Actions runner proves its identity to npm at publish time using OpenID Connect (OIDC):

  1. Your workflow runs. GitHub mints a short-lived OIDC token that says: "I am workflow publish.yml in repo you/your-package, running on ref main."
  2. npm publish exchanges that token with the registry.
  3. npm checks: does this package have a configured trust relationship matching that exact repo, workflow file, and (optionally) environment?
  4. If yes, npm issues a temporary, publish-scoped credential that expires almost immediately.

There's nothing to steal because there's nothing stored. An attacker who compromises your CI mid-run gets a credential that's scoped to one publish and dead within minutes.

What provenance adds on top

Trusted publishing controls who can publish. Provenance lets everyone else verify what was published.

When you publish from a trusted-publishing workflow, npm automatically generates a Sigstore attestation: a signed, publicly verifiable statement binding the package version to the source repository, the commit SHA, and the workflow run that built it. It shows up as the green "Provenance" badge on the package's npm page, and anyone can verify it locally:

npm audit signatures
Enter fullscreen mode Exit fullscreen mode

That command checks the Sigstore signature on every installed package against the public transparency log. If someone tampered with the tarball or published it from anywhere other than your declared workflow, verification fails.

The setup, step by step

1. Link the package on npm

On npmjs.com, go to your package → Settings → Publishing access → Trusted publishing. Add:

  • Repository owner: your GitHub username or org
  • Repository name: your repo
  • Workflow filename: the exact filename, e.g. publish.yml
  • Environment name: leave blank unless you use GitHub environments

Two things to know:

  • The workflow filename must match exactly, including extension. publish.yml ≠ publish.yaml.
  • For a brand-new package that doesn't exist on npm yet, you do one initial manual publish first (npm publish from your machine with 2FA), then configure trusted publishing for all future releases.

2. The workflow

Here's the actual workflow SolAudit uses (.github/workflows/publish.yml):

name: Publish to npm

on:
  workflow_dispatch:

permissions:
  id-token: write   # required for npm trusted publishing (OIDC)
  contents: read

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 24
          registry-url: https://registry.npmjs.org

      - uses: oven-sh/setup-bun@v2

      - name: Install dependencies
        run: bun install

      - name: Build self-contained CLI bundle
        run: bun run build:cli

      - name: Ensure npm supports trusted publishing
        run: npm install -g npm@latest

      - name: Publish solaudit to npm
        working-directory: cli
        run: npm publish
Enter fullscreen mode Exit fullscreen mode

Three details matter:

  • permissions: id-token: write — without this, the runner can't mint the OIDC token and the exchange fails.
  • No NODE_AUTH_TOKEN, no secrets block at all. The registry-url on setup-node is just for registry config, not auth.
  • npm install -g npm@latest — this is the gotcha that cost me a run. Trusted publishing requires a recent npm CLI (11.5.1+). The runner image ships an older one. Upgrade in the workflow or the OIDC exchange silently falls back to looking for a token you don't have.

3. Release

Bump the version, merge to main, then trigger the workflow manually from the Actions tab (that's what workflow_dispatch gives you — a big green "Run workflow" button). I keep publishing manual on purpose: merging code and shipping a public package are two different decisions, and I want a deliberate click between them.

4. Verify

After the run goes green:

npm view solaudit@0.3.0
npm audit signatures   # in any project that installs it
Enter fullscreen mode Exit fullscreen mode

And the package page on npmjs.com shows the provenance badge, linking to the public Sigstore transparency log entry with the source repo and commit.

What this looks like in practice

SolAudit 0.3.0 shipped this way this week. The full chain is inspectable:

  • The commit that bumped the version
  • The Actions run that published it
  • The npm page with the provenance badge

If I ever lose my laptop, get phished, or a dependency in my CI turns malicious, the blast radius for "publish as me" is: nothing. There is no credential to take.

The honest limitations

  • npm only, for now. Trusted publishing exists on PyPI and RubyGems too, but each registry implements it separately.
  • Your workflow file name is part of the trust config. Rename publish.yml and publishing breaks until you update npm's side.
  • It protects the publish step, not your code. If a malicious dependency lands in your actual build, provenance will faithfully attest to compromised code. You still need lockfile hygiene and dependency review.

But as a 30-minute change that deletes an entire credential class from your threat model, it's the best security-per-minute trade I've made on this project.


SolAudit is a free, open-source static analyzer for Anchor/Solana programs — 94% precision on its public benchmark, with documented limits. GitHub · npm

Top comments (0)