Publishing with npm Provenance in GitHub Actions
Add npm publish --provenance to a GitHub Actions workflow: set id-token: write, configure the registry, and emit a verifiable Sigstore attestation.
Adding --provenance to an existing npm publish command is not enough on its own. Many teams copy the flag into a workflow that has published successfully for years and hit:
npm error code EUSAGE
npm error Unable to generate provenance for @acme/[email protected].
npm error Provenance generation in GitHub Actions requires "write" access to the "id-token" permission
The flag depends on a permission most existing workflows never needed. This guide covers the exact permissions block, registry configuration, and job structure required to make Sigstore provenance generation succeed on the first try.
Root Cause
GitHub Actions workflows do not request an OIDC identity token unless a job explicitly declares id-token: write in its permissions block. npm’s CLI needs that token to exchange with Sigstore’s Fulcio certificate authority; without it, there is nothing to sign the provenance statement with, and the CLI aborts the publish rather than uploading an unsigned or falsely-attested tarball.
This is a deliberate fail-closed design: npm will not silently downgrade to a plain, unsigned publish when --provenance is requested but the identity token is unavailable.
Minimal Reproduction
This workflow reproduces the failure — it publishes correctly without --provenance, but fails the moment the flag is added, because the job’s permissions block never grants id-token:
name: Publish
on:
push:
tags: ['v*']
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read # id-token is missing here
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: 'https://registry.npmjs.org'
- run: npm ci && npm run build
- run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
How the OIDC Exchange Fails Without the Permission
Step-by-Step Fix
Step 1 — Add the id-token: write permission to the job
jobs:
publish:
runs-on: ubuntu-latest
- permissions:
- contents: read
+ permissions:
+ contents: read
+ id-token: write
Expected result: the job can now request a short-lived OIDC token scoped to this specific workflow run, repository, and ref.
Step 2 — Confirm the registry is configured before the publish step
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
+ registry-url: 'https://registry.npmjs.org'
- run: npm ci && npm run build
Expected result: ~/.npmrc on the runner is populated with a registry entry; without it, npm cannot determine which registry to negotiate the provenance upload with, even if the OIDC exchange itself succeeds.
Step 3 — Re-run the publish step
npm publish --provenance --access public
Expected output:
npm notice Publishing to https://registry.npmjs.org/ with tag latest and default access
npm notice === Provenance Statement ===
npm notice sigstore verification successful
npm notice + @acme/[email protected]
HAZARD PREVENTION
Symptom: The fix above still fails with the same
EUSAGEerror.Root cause: A workflow-level
permissionsblock set at the top of the YAML file can be overridden by a more restrictive job-level block further down — GitHub Actions does not merge them additively.Fix: Verify
id-token: writeis present on the job block itself (jobs.<job_id>.permissions), not only at the workflow root, and check that no matrix or reusable-workflow call downgrades it.
Verification
# Confirm the published version carries an attestation
npm view @acme/[email protected] dist.attestations.url
# Confirm registry + provenance signatures for a fresh install
npm ci
npm audit signatures
Expected output once the fix is applied:
audited 1 package in 0.4s
1 package has verified registry signatures
1 package has a verified provenance attestation
Edge Cases / Gotchas
- Reusable workflows (
workflow_call) need their ownpermissionsblock; the calling workflow’sid-token: writedoes not automatically propagate unless the reusable workflow also declares it. - Composite actions cannot declare
permissionsthemselves — the permission must live on the job that invokes the composite action. - Pull requests from forks never receive a write-scoped OIDC token, by GitHub design, so provenance publishing must be restricted to trusted trigger events like tag pushes on the base repository, not
pull_request. - Self-hosted runners work identically as long as the runner can reach
https://token.actions.githubusercontent.comand Sigstore’s public infrastructure — outbound network policies in locked-down environments sometimes block this silently. - Monorepos with multiple publishable packages need
id-token: writeon every job that runsnpm publish --provenance, not just the first one in a matrix.
Frequently Asked Questions
Do I need a paid GitHub plan to use id-token: write?
No. OIDC token issuance and the id-token: write permission are available on GitHub’s free tier for both public and private repositories. Provenance itself, however, is only accepted by the npm registry for packages published with public access.
Can I add --provenance to an existing workflow without touching permissions?
No. Adding the flag alone does nothing if id-token: write is absent — npm publish fails during the OIDC exchange. The permission must be declared explicitly; GitHub Actions does not grant it by default even to workflows that already have contents: write.
Does provenance work with reusable workflows or composite actions?
Yes, but the calling workflow must pass permissions through explicitly. A reusable workflow invoked with workflow_call needs its own permissions block, and the caller must not restrict id-token below write, or the OIDC token request will be denied.
Why does provenance fail specifically for a fork’s pull request build?
GitHub does not grant write-scoped OIDC tokens to workflows triggered by pull_request events from forks, as a security boundary. This prevents an external contributor’s PR from publishing a package under your identity. Provenance publishing must run from a trusted context such as a tag push on the base repository.
Is there a way to test provenance generation without actually publishing?
Not directly against Sigstore’s production Fulcio instance, since certificate issuance is tied to a real publish attempt. You can validate the surrounding configuration — permissions, registry-url, and package.json fields — with npm publish --dry-run --provenance, which reports configuration errors without contacting the registry.
Related
- npm Provenance & Sigstore Attestation — the full signing flow this fix restores, including how Fulcio and Rekor fit together.
- Verifying Package Provenance with npm audit signatures — confirm the attestation this fix produces is actually verifiable by consumers.
- Configuring OIDC Trusted Publishing for npm — the related but distinct flow that also removes the need for a stored
NPM_TOKEN.