---
title: npm Publish Without Tokens
url: https://varstatt.com/jurij/p/npm-trusted-publishing-from-github-actions
author: Jurij Tokarski
date: 2026-04-07
description: Trusted publishing with OIDC replaces long-lived npm tokens. The setup has one undocumented requirement that returns a misleading 404.
section: Blog (https://varstatt.com/jurij/archive)
tags: software-delivery (https://varstatt.com/jurij/c/software-delivery)
---

I published an npm package last week — [markdown-repository](https://www.npmjs.com/package/markdown-repository), a Firestore-style query builder for markdown files. The code worked. The tests passed. The release pipeline took longer to get right than the package itself.

## The Old Way

The standard npm publishing workflow uses a long-lived access token. You generate it on npmjs.com, store it as a GitHub Actions secret, and reference it in your workflow:

```yaml
- run: npm publish
  env:
    NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
```

It works, but the token never expires, has write access to your packages, and lives in plain text in your CI secrets. If it leaks — through a copied workflow file or a careless log — anyone can publish under your name.

npm's granular tokens improved this slightly. You can scope them to specific packages and set a 90-day expiration. But you still have to rotate them manually.

## Trusted Publishing

npm now supports [trusted publishing with OIDC](https://docs.npmjs.com/generating-provenance-statements#publishing-packages-with-provenance-via-trusted-publishing). Instead of a stored token, your GitHub Actions workflow proves its identity to npm using a short-lived OpenID Connect credential. npm verifies the credential against the workflow you've authorized, and accepts the publish.

No token to store. No token to rotate. No token to leak.

## First Publish Is Manual

Before you can configure trusted publishing, the package must already exist on the registry. npm has no "pending publisher" feature — you can't set up OIDC for a package that doesn't exist yet.

For the very first version, publish from your machine:

```bash
npm login
npm publish --access public
```

I spent a while debugging my workflow before realizing trusted publishing only works from the second release onward. Once the package exists on npmjs.com, go to its settings and add a trusted publisher. From that point, the workflow handles everything.

## Setting Up the Workflow

The setup has two parts.

**On npmjs.com**: go to your package settings, add a trusted publisher. Specify the GitHub org/user, repository, workflow filename, and optionally an environment name.

**In the workflow**: add `id-token: write` permission and an `environment` that matches what you configured on npm.

```yaml
name: Release

on:
  release:
    types: [published]

permissions:
  contents: read
  id-token: write

jobs:
  publish:
    runs-on: ubuntu-latest
    environment: release
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24.x
          registry-url: https://registry.npmjs.org
          cache: npm
      - run: npm ci
      - run: npm test
      - run: npm run build
      - run: npm publish --provenance --access public
```

Provenance attestation is automatic with trusted publishing. The `--provenance` flag is redundant but makes the intent explicit.

## The Misleading 404

My first three releases failed with this error:

```
npm error 404 Not Found - PUT https://registry.npmjs.org/markdown-repository
npm error 404 'markdown-repository@1.1.0' is not in this registry.
```

The package existed. The version was correct. The OIDC token exchange succeeded — I could see the signed provenance statement in [Rekor's transparency log](https://search.sigstore.dev). Everything worked except the actual publish.

The problem: **Node 22 ships with npm 10.x. Trusted publishing requires npm 11.5.1 or later.**

npm's documentation mentions this requirement. The error message doesn't. A 404 on PUT looks like a registry problem or a package name conflict. Nothing points you toward an npm version mismatch.

## The Fix

Use Node 24.x in your workflow. On GitHub Actions, `node-version: 24.x` resolves to a recent patch that includes npm 11.5.1+ — [markdown-repository](https://github.com/varstatt/markdown-repository/blob/main/.github/workflows/publish-package.yaml) publishes this way without an explicit npm upgrade.

```yaml
- uses: actions/setup-node@v4
  with:
    node-version: 24.x
```

If you're stuck on an older Node version, upgrade npm explicitly:

```yaml
- run: npm install -g npm@latest
```

With npm 11.5.1+, the same workflow publishes successfully. No tokens needed.

## The Environment Mismatch

The same 404 shows up when the **environment name** on npmjs.com doesn't match the `environment` field in your workflow job. If your workflow says `environment: release` but npm has the environment field blank (or vice versa), the OIDC claims don't match and npm rejects the publish — with a 404, not a meaningful error.

## What the Pipeline Looks Like Now

The full workflow for [markdown-repository](https://github.com/varstatt/markdown-repository) runs lint, tests, and build on every commit. On a GitHub release, it publishes to npm with provenance — no secrets configured anywhere in the repository.

If your CI/CD has shapes like this hiding in it — broken pipelines, secret sprawl, opaque failures — that's the kind of thing a [DevOps audit](/jurij/p/what-a-devops-audit-looks-like) is for.
