Publishing npm Packages with Provenance
Permalink to "Publishing npm Packages with Provenance"Part of Provenance Verification Workflows, this page covers the publisher’s half of the chain: how npm publish --provenance turns an ordinary CI job into a signed, publicly logged statement about which repository, commit and workflow produced the tarball your users install.
Quick Reference
Permalink to "Quick Reference"| Item | Value | Notes |
|---|---|---|
| Flag | npm publish --provenance |
Or NPM_CONFIG_PROVENANCE=true, or publishConfig.provenance |
| Minimum npm | 9.5.0 |
Ships with Node.js 18.15+ and all Node.js 20+ releases |
| Required permissions | id-token: write, contents: read |
GitHub Actions job or workflow block |
| Supported providers | GitHub Actions, GitLab CI/CD | The CLI detects the provider from environment variables |
| Registry | https://registry.npmjs.org |
Private registries and GitHub Packages are not supported |
| Package access | public |
Restricted scoped packages are rejected |
| Predicate type | https://slsa.dev/provenance/v1 |
SLSA v1.0 build provenance, in-toto statement |
| Transparency log | https://rekor.sigstore.dev |
Public, append-only, no opt-out |
| Attestation endpoint | /-/npm/v1/attestations/<pkg>@<version> |
Served from registry.npmjs.org |
Default publishing recipe: a public package, a supported CI provider, id-token: write, and npm publish --provenance --access public as the last step of a tagged release job.
The mental model
Permalink to "The mental model"A tarball checksum answers one question: did I receive the bytes the registry stored? It says nothing about who produced those bytes or from what source. Provenance answers the second question by having the build itself sign a statement about its own inputs, at the moment it runs, from inside the environment that did the work.
The mechanism deliberately avoids a signing key you have to protect. When npm publish --provenance starts, the CLI asks the CI provider for a short-lived OpenID Connect identity token describing the running job — on GitHub Actions that is the token the id-token: write permission unlocks, minted by https://token.actions.githubusercontent.com. The CLI presents that token to Sigstore’s Fulcio certificate authority, which issues a code-signing certificate valid for roughly ten minutes whose extensions encode the repository, the workflow file, the git ref and the commit SHA taken from the token’s claims. The CLI signs an in-toto statement with the matching ephemeral key, submits the signature to the Rekor transparency log, receives an inclusion proof, and uploads the resulting Sigstore bundle to the registry with the tarball. The private key is discarded; nothing durable is left to steal.
That inversion is the point. With a classic signing key, compromise is silent and open-ended — whoever holds the key can sign anything, forever. Here the equivalent capability is “the right to run that specific workflow, on that specific repository, at that moment”, which is auditable in your CI logs and revocable by changing repository permissions.
The last exchange is the one users see. Once the registry has accepted the bundle, the package page on npmjs.com grows a provenance panel that names the source repository, links the exact commit, and links the CI run that produced the version — and the same data becomes available to tooling through the attestations endpoint.
What the attestation records
Permalink to "What the attestation records"The signed payload is an in-toto statement, not a free-form blob, and every field in it exists to answer a specific verifier question. The subject array names the package as <name>@<version> and carries the SHA-512 digest of the exact tarball, which is what binds the statement to bytes rather than to a name. The predicateType is https://slsa.dev/provenance/v1, so any SLSA-aware verifier knows how to read the rest. Inside the predicate, buildDefinition.externalParameters records the workflow that was invoked — repository, workflow file path and ref — while resolvedDependencies pins the source repository URI together with the commit SHA that was checked out. runDetails.builder.id identifies the builder, which for GitHub-hosted runners is a URI under https://github.com/actions/runner, and runDetails.metadata.invocationId points at the run and attempt number so a human can open the log.
The registry adds a second attestation of its own, counter-signing the fact that this name and version were published through this identity. Both are returned by the attestations endpoint, and both are what npm audit signatures consults on the consumer side. Note what is absent: the statement says nothing about the contents of the source, whether a review happened, or whether the dependencies pulled in during the build were themselves trustworthy. It is an origin claim, not a safety claim.
Canonical example: a release workflow that publishes with provenance
Permalink to "Canonical example: a release workflow that publishes with provenance"Four preconditions must hold before the flag does anything useful. The package must be public, it must go to registry.npmjs.org, the job must run on a provider the CLI recognises, and package.json must contain a repository field pointing at the repository that is running the build. Start with the manifest:
{
"name": "@acme/widget-kit",
"version": "2.1.0",
"repository": {
"type": "git",
"url": "git+https://github.com/acme/widget-kit.git"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org"
}
}
Then the workflow. Note that permissions is declared on the job, that it grants exactly two scopes, and that actions/setup-node is given a registry-url so it writes an .npmrc that reads NODE_AUTH_TOKEN:
# .github/workflows/release.yml
name: release
on:
push:
tags: ["v*"]
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read # checkout the tagged commit
id-token: write # request the OIDC token that Fulcio signs against
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Publish with provenance
run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
The publish step is the whole feature. --provenance triggers the OIDC exchange and the Rekor submission; --access public makes the intent explicit for a scoped package whose first publish would otherwise default to restricted. A successful run prints a npm notice Provenance statement published to transparency log line containing the Rekor search URL, and that URL is the fastest way to confirm the entry landed.
Variants
Permalink to "Variants"Turn provenance on in the manifest instead of the command line
Permalink to "Turn provenance on in the manifest instead of the command line"If several scripts or a release tool such as changesets invoke npm publish for you, moving the setting into package.json is more reliable than threading a flag through:
{
"publishConfig": {
"access": "public",
"provenance": true
}
}
NPM_CONFIG_PROVENANCE=true in the job environment has the same effect and is convenient when a monorepo publishes many packages from one step.
Publish from GitLab CI/CD
Permalink to "Publish from GitLab CI/CD"GitLab is the other supported provider. Instead of a permissions block, you declare an ID token with the sigstore audience, which the CLI looks for under the fixed name SIGSTORE_ID_TOKEN:
publish:
image: node:20
id_tokens:
SIGSTORE_ID_TOKEN:
aud: sigstore
script:
- npm ci
- npm run build
- npm publish --provenance --access public
rules:
- if: $CI_COMMIT_TAG
The attestation’s build definition then names the GitLab project and pipeline rather than a GitHub workflow, and verifiers must expect the GitLab OIDC issuer rather than token.actions.githubusercontent.com.
Drop the long-lived token with trusted publishing
Permalink to "Drop the long-lived token with trusted publishing"Registering the repository and workflow as a trusted publisher on npmjs.com lets the same OIDC token authenticate the publish itself, so NODE_AUTH_TOKEN and the secrets.NPM_TOKEN entry disappear entirely and provenance is generated without the flag. The workflow shrinks to the checkout, the build and a bare npm publish, and the only credential in play is one that cannot be replayed outside that job. This closes the gap the attestation alone leaves open: an attacker who steals a classic automation token can still push a release, and no provenance statement on earlier versions prevents that.
When the publish is rejected
Permalink to "When the publish is rejected"Three preconditions fail loudly, and the messages are specific enough to diagnose from the log alone. If the job never received an OIDC identity — the usual cause is a permissions block that omits id-token, or one declared at workflow level while a called reusable workflow re-declares its own — the CLI stops before contacting the registry:
npm error code EUSAGE
npm error Provenance generation in GitHub Actions requires "write" access to the "id-token" permission
If the package is scoped and would land as restricted, or is a brand-new name being created without explicit public access, the CLI refuses for a different reason:
npm error code EUSAGE
npm error Can't generate provenance for new or private package, you must set `access` to public.
The third failure comes from the registry rather than the CLI, because only the registry can compare the attestation against the manifest. When repository.url in package.json names a different repository than the one the workflow is running in — a stale URL after a rename or transfer is the common case — the PUT is rejected:
npm error code E422
npm error 422 Unprocessable Entity - PUT https://registry.npmjs.org/@acme%2fwidget-kit - Unable to reconcile the package's repository field with the provenance statement
The stable, reliable part of that third message is code E422 on the publish PUT; the explanatory suffix has been reworded across registry and CLI releases, so match on the code rather than the sentence when scripting around it. On npm 9 all three appear with the older npm ERR! prefix instead of npm error.
Gotchas and Edge Cases
Permalink to "Gotchas and Edge Cases"-
Permissions do not flow into reusable workflows. A
permissionsblock on the calling workflow is not inherited by a workflow invoked withuses: owner/repo/.github/workflows/x.yml@ref. The called workflow needs its ownpermissions: { id-token: write }, and the caller must pass at least as much. This is the single most common cause of theid-tokenerror in an otherwise correct pipeline. -
Fork pull requests never get an OIDC token. GitHub withholds
id-tokenfrom workflows triggered by a fork’s pull request, by design. Any publish path that could be reached frompull_requestwill fail, so drive releases from tag pushes,workflow_dispatch, or a protected release environment instead. -
The transparency log entry is permanent and public. Rekor is append-only and there is no withdrawal mechanism. Publishing with provenance from a repository whose name you consider sensitive discloses that name, the workflow file path and the commit SHA forever. Decide this before the first release, not after.
-
Provenance proves origin, not safety. A malicious commit merged into the right repository produces a perfectly valid attestation. Provenance narrows the question from “did anyone tamper with this?” to “do I trust this repository and its review process?”, which is why it belongs beside install-time controls such as Disabling npm Install Scripts rather than replacing them.
-
Monorepos need a
directoryon every package. Each publishedpackage.jsonmust carry arepository.urlfor the shared repository plusrepository.directorynaming its own path, for examplepackages/widget-kit. A copied-and-pasted manifest that still names the template repository will pass local tooling and fail at the registry withE422. -
A provenance attestation does not give browsers anything. It protects the install, not the page load. If you also ship a CDN build of the package, the served file still needs a Subresource Integrity hash, and any tag carrying
integritymust also carrycrossorigin="anonymous"or the browser drops the check entirely.
Verification Steps
Permalink to "Verification Steps"1. Confirm the OIDC exchange happened
Permalink to "1. Confirm the OIDC exchange happened"Read the publish step’s log. On success the CLI reports the log submission before the upload summary:
npm publish --provenance --access public
Expected excerpt:
npm notice Publishing to https://registry.npmjs.org/ with tag latest and public access
npm notice Provenance statement published to transparency log: https://search.sigstore.dev/?logIndex=...
If the Provenance statement line is missing, no attestation was produced regardless of whether the publish itself succeeded.
2. Fetch the attestation from the registry
Permalink to "2. Fetch the attestation from the registry"curl -sf "https://registry.npmjs.org/-/npm/v1/attestations/@acme/[email protected]" \
| jq '.attestations[].predicateType'
Expected output — two entries, the SLSA build provenance and the registry’s own publish attestation:
"https://slsa.dev/provenance/v1"
"https://github.com/npm/attestation/tree/main/specs/publish/v0.1"
A 404 means the version was published without provenance; republishing is not possible, so ship a new version.
3. Read back the repository and commit the attestation claims
Permalink to "3. Read back the repository and commit the attestation claims"curl -sf "https://registry.npmjs.org/-/npm/v1/attestations/@acme/[email protected]" \
| jq -r '.attestations[0].bundle.dsseEnvelope.payload' \
| base64 -d \
| jq '.predicate.buildDefinition.externalParameters.workflow'
Expected output names your repository, the workflow file and the tag ref:
{
"ref": "refs/tags/v2.1.0",
"repository": "https://github.com/acme/widget-kit",
"path": ".github/workflows/release.yml"
}
Any mismatch here means the badge is pointing somewhere you did not intend.
4. Confirm the badge, then verify as a consumer would
Permalink to "4. Confirm the badge, then verify as a consumer would"Open the version’s page on npmjs.com and check that the provenance panel lists the source commit and the build run. Then verify from the outside, which is the check that actually matters — the full procedure, including the identity constraints to assert and how to gate a pipeline on the result, is in Verifying Sigstore Provenance for npm Packages, and the broader build-attestation policy that wraps it is covered in Verifying SLSA Build Provenance in CI.
If you also publish a browser bundle to a CDN, pin it separately once the release is verified:
<script
src="https://cdn.jsdelivr.net/npm/@acme/[email protected]/dist/widget-kit.min.js"
integrity="sha384-Uj01EXFqAuMOV6neXrevcsKNIusIJQItxQSmp/yA1lViSfh+6oLt+iWWTj3HnsHs"
crossorigin="anonymous"
></script>
Emitting those digests from the same release job is covered by Generating an SRI Manifest in GitHub Actions.
Frequently Asked Questions
Permalink to "Frequently Asked Questions"Does npm provenance work outside GitHub Actions?
Yes, but only from CI providers the npm CLI recognises. GitHub Actions and GitLab CI/CD are the supported providers; on GitLab you must expose an ID token named SIGSTORE_ID_TOKEN with the sigstore audience. Publishing from a laptop, a self-managed Jenkins server, or an unrecognised runner fails because the CLI has no OIDC issuer to obtain a signing certificate from.
Can I publish a private or restricted package with provenance?
No. Provenance requires a public package on the public npm registry, because the attestation and its transparency log entry are themselves public artifacts. A restricted scoped package fails with an EUSAGE error telling you to set access to public. Private registries and GitHub Packages do not accept npm provenance attestations at all.
Does provenance replace my npm automation token?
Not by itself. The --provenance flag governs how the attestation is signed, not how you authenticate to the registry, so a token-based workflow still needs NODE_AUTH_TOKEN. Configuring the repository as a trusted publisher on npmjs.com is the separate change that removes the long-lived token, and it turns provenance on automatically for those publishes.
What does the transparency log entry expose publicly?
The Rekor entry records the signing certificate, which carries the repository URL, the workflow file path, the git ref and the commit SHA in its certificate extensions, plus the digest of the published tarball. It never contains source code or secrets, but repository and workflow names become permanently public. Rekor is append-only, so an entry cannot be withdrawn.
Do I need to store the attestation bundle anywhere myself?
No. The registry stores the bundle and serves it from its attestations endpoint, and Rekor holds an independent inclusion proof. Committing a copy to your repository adds a stale artifact that verifiers have no reason to trust. Keep the build reproducible instead, so the digest in the attestation can be reasoned about later.
Related
Permalink to "Related"- Registry & Package Manager Hardening — the registry-side controls that decide what a compromised publish can reach in the first place
- Detecting Lockfile Tampering in Pull Requests — catching a swapped resolved URL or digest before it ever reaches a release build
- Supply Chain Auditing & Dependency Verification — how attestation, auditing and hash pinning fit together across a whole pipeline