# Package Provenance

> Package provenance is a signed record of the repository, commit, and CI workflow that built a package file. npm provenance and PyPI attestations implement it.

Also known as: npm provenance, npm package provenance, provenance attestation, build provenance, PyPI digital attestations, software chain of custody

Source: https://socket.dev/glossary/package-provenance

Last updated: 2026-11-03

Package provenance is a signed statement, generated by a CI build platform, that names the source repository, commit, and workflow that produced a specific package file, bound to that file's hash. npm provenance and PyPI digital attestations both implement it with Sigstore. Package provenance proves where and how a package was built, not whether its code is safe.

The same record goes by several names: npm provenance on npm, digital attestations on PyPI, and build provenance in the [SLSA](https://socket.dev/glossary/supply-chain-levels-for-software-artifacts) specification. Mechanically, a CI job trades its OpenID Connect (OIDC) identity token for a short-lived [Sigstore](https://socket.dev/glossary/sigstore) certificate that encodes its workflow identity, signs an in-toto statement containing the package file's SHA hash, and records the signature in a public transparency log. The registry stores the statement next to the file.

The most important fact about package provenance is what it leaves out. It names a commit, and it attests the hash of the file that came out of the build, but nothing in it shows that the commit produced the file. On November 3, 2026, we compared zod@4.6.7 with the commit its provenance names. 505 of its 840 files are not in that commit, including the index.js and index.cjs files Node actually loads. The build generated them. Our position: verify provenance, pin the repository and workflow you expect, and treat a change in either as a security event. Then analyze what is actually in the tarball, because the code you run is the part no one attested.

## What is npm package provenance?

npm package provenance, usually called npm provenance, is npm's implementation of package provenance. GitHub [announced it](https://github.blog/security/supply-chain-security/introducing-npm-package-provenance/) on April 19, 2023, and we explained what it does and does not check in our post on [npm's provenance launch](https://socket.dev/blog/npm-provenance). A package gets it when a maintainer publishes from GitHub Actions or GitLab CI/CD on a cloud-hosted runner with npm CLI 9.5.0 or later, per [npm's documentation](https://docs.npmjs.com/generating-provenance-statements). Since [npm adopted trusted publishing](https://socket.dev/blog/npm-trusted-publishing) in July 2025, the CLI also generates provenance automatically whenever a package is published through a [trusted publisher](https://socket.dev/glossary/npm-trusted-publishing).

Start with what a registry knows without provenance. `npm publish` packs a tarball on whatever machine runs it, and the registry stores those bytes under the publisher's credential. The `repository` field in package.json is a free-text claim. Nothing ties the tarball to a commit, which is what makes [starjacking](https://socket.dev/glossary/starjacking) work: a package can point its repository field at a popular project it has nothing to do with.

Provenance adds two checks at publish time, documented in [npm's provenance design notes](https://github.com/npm/provenance). The registry verifies that the package name, version, and SHA-512 match the signed statement's subject. It also verifies that the `repository` field in package.json matches the source repository in the signing certificate. A starjacked repository field cannot carry a provenance badge.

The result is visible in the version document the registry serves for every release:

```bash
$ curl -s https://registry.npmjs.org/semver/7.8.6 \
    | jq '{gitHead, repository, _npmUser: ._npmUser | {name, trustedPublisher}, dist: .dist | {integrity, attestations}}'
{
  "gitHead": "b37b033b585ad5e575afeccadb426ea41bc6cea4",
  "repository": { "url": "git+https://github.com/npm/node-semver.git", "type": "git" },
  "_npmUser": {
    "name": "GitHub Actions",
    "trustedPublisher": { "id": "github", "oidcConfigId": "7a0c479c-0252-4ccd-81e5-c43546a76551" }
  },
  "dist": {
    "integrity": "sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==",
    "attestations": {
      "url": "https://registry.npmjs.org/-/npm/v1/attestations/semver@7.8.6",
      "provenance": { "predicateType": "https://slsa.dev/provenance/v1" }
    }
  }
}
```

The attestations URL returns two signed statements. The publish attestation, with predicate type `https://github.com/npm/attestation/tree/main/specs/publish/v0.1`, is signed by the registry with npm's own key and says only that npm accepted this name, version, and digest. The provenance attestation, with predicate type `https://slsa.dev/provenance/v1`, is signed inside the CI job and carries the build description.

## What a provenance attestation records

A provenance attestation is a Dead Simple Signing Envelope (DSSE) whose payload is an in-toto statement. Decoded, the statement for semver@7.8.6 reads:

```json
{
  "_type": "https://in-toto.io/Statement/v1",
  "subject": [{
    "name": "pkg:npm/semver@7.8.6",
    "digest": { "sha512": "f65a52494e0c972de6aedaeec0bb3578b2bafa0cdbcea78117044d8c88c4fbd2ee0e959d61cb6b4254377a3d489608bb8249e9b134673e82cff03a421ea1c827" }
  }],
  "predicateType": "https://slsa.dev/provenance/v1",
  "predicate": {
    "buildDefinition": {
      "buildType": "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1",
      "externalParameters": {
        "workflow": { "ref": "refs/heads/main", "repository": "https://github.com/npm/node-semver", "path": ".github/workflows/release.yml" }
      },
      "internalParameters": {
        "github": { "event_name": "push", "repository_id": "1357199", "repository_owner_id": "6078720" }
      },
      "resolvedDependencies": [{
        "uri": "git+https://github.com/npm/node-semver@refs/heads/main",
        "digest": { "gitCommit": "b37b033b585ad5e575afeccadb426ea41bc6cea4" }
      }]
    },
    "runDetails": {
      "builder": { "id": "https://github.com/actions/runner/github-hosted" },
      "metadata": { "invocationId": "https://github.com/npm/node-semver/actions/runs/41486847600/attempts/1" }
    }
  }
}
```

Each field answers one narrow question:

| Field | Value for semver@7.8.6 | What it tells you |
| --- | --- | --- |
| subject.digest.sha512 | f65a5249…a1c827 | Which exact tarball the statement covers. It equals the registry's dist.integrity, decoded from base64. |
| workflow.repository, workflow.path | npm/node-semver, release.yml | Which repository and workflow file started the run. |
| resolvedDependencies gitCommit | b37b033b…bc6cea4 | The commit the run was triggered on. |
| event\_name | push | What triggered the run. |
| repository\_id, repository\_owner\_id | 1357199, 6078720 | Numeric IDs that survive a rename or transfer, unlike owner/repo names. |
| builder.id | github-hosted | The run used GitHub's hosted runners, not a machine the maintainer controls. |
| invocationId | runs/41486847600/attempts/1 | A link to the run and its logs. |

The envelope is signed with a certificate from Fulcio, Sigstore's certificate authority, valid for 10 minutes, from 17:04:44 to 17:14:44 UTC on November 2, 2026. Rekor, Sigstore's transparency log, recorded the signature at 17:04:45 UTC at log index 3,644,633,127. Decoded with `openssl x509 -inform der -text` and trimmed, the certificate shows one detail that trips up most verification policies:

```text
X509v3 Subject Alternative Name: critical
    URI:https://github.com/npm/node-semver/.github/workflows/release-integration.yml@refs/heads/main
1.3.6.1.4.1.57264.1.1:  https://token.actions.githubusercontent.com          # OIDC issuer
1.3.6.1.4.1.57264.1.3:  b37b033b585ad5e575afeccadb426ea41bc6cea4             # commit
1.3.6.1.4.1.57264.1.11: github-hosted                                        # runner environment
1.3.6.1.4.1.57264.1.18: https://github.com/npm/node-semver/.github/workflows/release.yml@refs/heads/main   # build config
1.3.6.1.4.1.57264.1.21: https://github.com/npm/node-semver/actions/runs/41486847600/attempts/1
```

The statement names `release.yml`. The certificate's Subject Alternative Name (SAN), the identity Sigstore actually verified, names `release-integration.yml`. semver's `release.yml` calls `release-integration.yml` as a reusable workflow, and that reusable workflow runs `npm publish --provenance`. [Fulcio's OID reference](https://github.com/sigstore/fulcio/blob/main/docs/oid-info.md) records the caller separately, as the Build Config URI. A policy that copies the workflow path from the statement into the signer check rejects this legitimate release. We show that failure below.

## How do PyPI digital attestations differ from npm provenance?

PyPI digital attestations carry less in the signed statement and more in the certificate. [PEP 740](https://peps.python.org/pep-0740) defines the format. [PyPI's digital attestations launch](https://socket.dev/blog/pypi-introduces-digital-attestations) on November 14, 2024 arrived with more than 20,000 attestations already uploaded. Projects that publish through a trusted publisher with PyPA's official GitHub action get them by default.

PyPI attests each file, not each release. The Simple JSON API points every wheel and sdist at its own provenance object:

```bash
$ curl -s -H 'Accept: application/vnd.pypi.simple.v1+json' https://pypi.org/simple/urllib3/ \
    | jq '.files[] | select(.filename == "urllib3-2.8.0-py3-none-any.whl") | {filename, hashes, provenance}'
{
  "filename": "urllib3-2.8.0-py3-none-any.whl",
  "hashes": { "sha256": "0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3" },
  "provenance": "https://pypi.org/integrity/urllib3/2.8.0/urllib3-2.8.0-py3-none-any.whl/provenance"
}
```

That provenance object groups attestations by publisher. Decoded, its single bundle reads:

```json
{
  "publisher": { "kind": "GitHub", "repository": "urllib3/urllib3", "workflow": "publish.yml", "environment": "publish" },
  "statement": {
    "_type": "https://in-toto.io/Statement/v1",
    "subject": [{ "name": "urllib3-2.8.0-py3-none-any.whl",
                  "digest": { "sha256": "0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3" } }],
    "predicateType": "https://docs.pypi.org/attestations/publish/v1",
    "predicate": null
  }
}
```

The predicate is `null`. There is no SLSA build definition and no commit in the statement. The commit `b1d30ab61fe0db8f11092805e8c5ac43e091064a`, the ref `refs/tags/2.8.0`, and the deployment environment `publish` exist only in the Fulcio certificate's extensions, issued at 19:29:31 UTC on September 15, 2026. A PyPI verifier has to read the certificate to learn what an npm verifier reads from the statement. The guarantee is the same: this file came out of this repository's `publish.yml`, running on that commit.

## How do you verify a package's provenance?

Verifying a package's provenance takes four checks. The signature must chain to Sigstore's trust root. The transparency log must have recorded it while the certificate was valid. The subject digest must equal the hash of the file you downloaded. The signer must be the identity you expected. Tools do the first three. The fourth is policy, and it is the only one that says anything about who built the package.

On npm, the built-in check is `npm audit signatures`. Per npm's design notes, it verifies the attestation against the installed name, version, and tarball, confirms the registry accepted the publish, chains the certificate to Sigstore's root, and checks the Rekor timestamp. It does not compare the signer with any expected identity. Nothing else in the client verifies provenance at all. We read npm 12.2.1, the latest release as of November 2026, and its fetcher pacote 22.0.1: the only code path that sets `verifyAttestations: true` is the one behind `npm audit signatures`. `npm install` never verifies attestations.

`npm audit signatures` failed in our sandbox with "Failed to download", because it could not reach Sigstore's The Update Framework (TUF) repository, which distributes the trust root. So we verified the semver@7.8.6 bundle offline with the `@sigstore/verify` library npm ships, using the trusted root npm bundles as its seed. We tried four identity policies:

```javascript
// verify.cjs: run from a directory holding att.json, the attestations response for semver@7.8.6
const N = '/opt/node22/lib/node_modules/npm/node_modules/';
const { Verifier, toSignedEntity, toTrustMaterial } = require(N + '@sigstore/verify');
const { bundleFromJSON } = require(N + '@sigstore/bundle');
const { TrustedRoot } = require(N + '@sigstore/protobuf-specs');
const seeds = require(N + '@sigstore/tuf/seeds.json');
const root = JSON.parse(Buffer.from(Object.values(seeds)[0].targets['trusted_root.json'], 'base64'));
const verifier = new Verifier(toTrustMaterial(TrustedRoot.fromJSON(root)));
const att = require('./att.json').attestations.find(a => a.predicateType.includes('slsa'));
const entity = toSignedEntity(bundleFromJSON(att.bundle));
const wf = (repo, file) => ({
  subjectAlternativeName: `https://github.com/${repo}/.github/workflows/${file}@refs/heads/main`,
  extensions: { issuer: 'https://token.actions.githubusercontent.com' },
});
for (const [label, policy] of [
  ['no policy', undefined],
  ['predicate workflow path', wf('npm/node-semver', 'release.yml')],
  ['signer workflow', wf('npm/node-semver', 'release-integration.yml')],
  ['lookalike repo', wf('npm/node-semvar', 'release-integration.yml')],
]) {
  try { verifier.verify(entity, policy); console.log(`${label}: OK`); }
  catch (e) { console.log(`${label}: FAIL ${e.code}`); }
}
```

```text
no policy: OK
predicate workflow path: FAIL UNTRUSTED_SIGNER_ERROR
signer workflow: OK
lookalike repo: FAIL UNTRUSTED_SIGNER_ERROR
```

The first line is the one `npm audit signatures` gives you: valid for any identity Fulcio ever certified. The second line is the reusable-workflow trap from the certificate above. The third line is a correct policy. The fourth line is the policy doing its job against a one-letter lookalike.

On PyPI, the `pypi-attestations` CLI refuses to verify without an expected identity. We downloaded the urllib3 wheel, confirmed its SHA-256 matches the attested digest, and ran it three ways, offline against the provenance object fetched above, saved as provenance.json:

```bash
$ pypi-attestations verify pypi --offline --provenance-file provenance.json \
    urllib3-2.8.0-py3-none-any.whl
Provenance signed by a repository, but no repository URL provided; use '--repository'

$ pypi-attestations verify pypi --offline --provenance-file provenance.json \
    --repository https://github.com/urllib3/urllib4 urllib3-2.8.0-py3-none-any.whl
Verification failed: provenance was signed by repository "urllib3/urllib3", expected "urllib3/urllib4"

$ pypi-attestations verify pypi --offline --provenance-file provenance.json \
    --repository https://github.com/urllib3/urllib3 urllib3-2.8.0-py3-none-any.whl
OK: urllib3-2.8.0-py3-none-any.whl
```

That design choice is the right one: PyPI's tool will not let you skip the identity check, and npm's will not let you run it. For artifacts outside registries, `gh attestation verify` plays the same role for GitHub artifact attestations, and its `--signer-workflow` flag pins the reusable workflow that signed.

## The gap between the commit and the tarball

Package provenance is a chain of custody with two signed links. In forensics, a chain of custody documents every hand that held a piece of evidence between collection and court. Package provenance signs the commit going into the build and the digest coming out. Everything in between is attested only as "this workflow ran on this commit."

> Solid boxes are covered by signatures or registry checks. Hatched boxes are the stages package provenance does not attest.
>
> What package provenance signs across the path from commit to install

The figure traces semver@7.8.6 through five stages. Stage 1 is the source commit, npm/node-semver at b37b033b on refs/heads/main: the repository, ref, and commit SHA the run was triggered on are signed. Stage 2 is the build job, which runs checkout, cache restore, dependency install, compile, and npm pack: the workflow file, run ID, and hosted runner are signed, which proves the job ran, but what each step did, the dependency and cache contents, any extra checkouts, and every file the build generated are not attested. Stage 3 is the tarball, semver-7.8.6.tgz with SHA-512 f65a5249…a1c827: its digest is signed as the statement subject. Stage 4 is the registry, which checks at publish time that the digest matches the subject and that package.json's repository field matches the certificate. Stage 5 is your install: as of npm 12.2.1, npm install never verifies attestations, and the opt-in npm audit signatures checks the certificate chain and digest but not the signer identity.

So we asked the question provenance does not answer: does the tarball contain what the commit contains? The check is short. Download the tarball, check out the commit its provenance names, and hash every file:

```python
# compare.py: classify each file in an npm tarball against the provenance commit
import hashlib, os, sys
tarball_dir, source_dir = sys.argv[1], sys.argv[2]  # tar xzf *.tgz; git checkout <gitCommit>
counts = {"identical": 0, "differs": 0, "tarball-only": 0}
for root, _, files in os.walk(tarball_dir):
    for name in files:
        rel = os.path.relpath(os.path.join(root, name), tarball_dir)
        src = os.path.join(source_dir, rel)
        if not os.path.exists(src):
            counts["tarball-only"] += 1
        elif hashlib.sha256(open(src, "rb").read()).digest() == hashlib.sha256(open(os.path.join(root, name), "rb").read()).digest():
            counts["identical"] += 1
        else:
            counts["differs"] += 1
print(counts)
```

```text
$ python3 compare.py semver/package node-semver@b37b033
{'identical': 54, 'differs': 0, 'tarball-only': 0}
$ python3 compare.py zod/package zod@b500a2ee/packages/zod
{'identical': 335, 'differs': 0, 'tarball-only': 505}
```

> Files in each npm tarball, classified against the commit named in its provenance attestation. Measured November 3, 2026.
>
> Share of tarball files that exist in the provenance commit, semver 7.8.6 versus zod 4.6.7

| Package | Files in tarball | Identical to provenance commit | Only in tarball | Build step |
| --- | --- | --- | --- | --- |
| semver@7.8.6 | 54 | 54 (100%) | 0 | None |
| zod@4.6.7 | 840 | 335 (40%) | 505 (60%): 124 .js, 124 .cjs, 124 .d.ts, 124 .d.cts, 9 package.json | TypeScript compile |

semver has no build step. All 54 files in semver@7.8.6 are byte-identical to commit b37b033b, so the provenance and the source together tell you exactly what you install. zod is TypeScript compiled at release. Of the 840 files in zod@4.6.7, 335 match commit b500a2ee byte for byte: the README, the license, package.json, and the TypeScript sources under src/. The other 505 are not in the commit at all: 124 .js files, 124 .cjs files, 248 type declarations (.d.ts and .d.cts), and 9 nested package.json files. package.json points `import` at ./index.js and `require` at ./index.cjs. Every line of zod that Node executes is in the 505.

That is normal for any package with a build step, and it is why the only full check is a rebuild. [vlt's reproduce launch](https://socket.dev/blog/vlt-launches-reproduce) in February 2025 brought a tool that clones the attested commit, rebuilds, and compares integrity hashes. When vlt tested 5,000 high-impact npm packages, 5.78% reproduced and only 3.72% had provenance. Reproducibility, the stronger check, was already more common than provenance.

The diff matters because of how pipelines get compromised. In the [openapi-react-query-codegen compromise](https://socket.dev/blog/openapi-react-query-codegen-npm-compromise) on August 28, 2026, all ten malicious versions carried valid npm provenance naming commit d42d1733, the clean v3.0.2 commit. The release workflow ran on `issue_comment`, so GitHub recorded the default branch as the trigger, and then a step checked out a stranger's fork. The tarballs carried a 5.6 MB `3FWCvzduYZg.js` loader and a malicious `binding.gyp` that are not in the commit their provenance names. A tarball-to-commit diff would have listed both files. In a package that compiles its code, they would have appeared alongside hundreds of legitimate build outputs, so only a rebuild separates them cleanly.

## What does package provenance prove, and what does it not?

Package provenance proves that a specific CI workflow, running on a specific commit, produced a file with a specific hash. It also proves nobody changed that file after the build, on the registry or on any mirror. Forging it means compromising Sigstore, the CI provider's OIDC issuer, or the transparency log.

Package provenance does not prove the source was what the maintainers intended, that the build's inputs were clean, or that every step in the job was trustworthy. PyPI's [attestation security model](https://docs.pypi.org/attestations/security-model/) says it directly: "An attestation will tell you where a PyPI package came from, but not whether you should trust it."

The [keyv and cacheable compromise](https://socket.dev/supply-chain-attacks/keyv-and-cacheable-compromise) on August 4, 2026 sits entirely outside what provenance can see. keyv@6.0.0 shipped with a passing attestation because the legitimate release workflow built source that was already trojanized. A provenance check could not flag it, and a tarball-to-commit diff would not help either, because the workflow faithfully built the trojanized source it was given. The contents told the story: the package's dist/ output was byte-identical to the clean 6.0.0-rc.1 build, and the only changes were package.json and two new files, a `setup.mjs` preinstall loader and a 728 KB `Math_Symbol.js` payload. Socket's malware detection flagged keyv@6.0.0 about six minutes after publication by analyzing those files, as we documented in our [write-up of the keyv attack](https://socket.dev/blog/popular-npm-packages-in-the-keyv-and-cacheable-namespaces-compromised-in-active-supply-chain). Our conclusion there still holds: provenance attests build integrity, not source integrity.

The deeper failure mode is a compromised CI job. Any code that runs inside a job holding an OIDC grant can obtain the same signing identity as the release step, which is why [GitHub Actions security](https://socket.dev/glossary/github-actions-security) is now part of every package's provenance.

Provenance is necessary but not sufficient, and it still earns its keep after an incident. In the [Ultralytics compromise](https://socket.dev/blog/ultralytics-pypi-package-compromised-through-github-actions-cache-poisoning), the attestations on the malicious PyPI releases carried signed timestamps and source references that helped researchers confirm GitHub Actions cache poisoning. Security researcher Adnan Khan, who analyzed the attack, put the limit precisely: "Attestation only creates a tamper-proof guarantee between an artifact and a build job + commit."

## Provenance, signatures, SBOMs, and reproducible builds

Four mechanisms compete to answer "can I trust where this package came from?" Each answers a different question:

| Mechanism | Question it answers | Catches | Misses | Cost |
| --- | --- | --- | --- | --- |
| Code signing | Who released these bytes? | Tampering after signing; impostors without the key | Anything the key holder signs, including malware from a stolen key | Key management and revocation |
| Package provenance | Which repo, commit, and workflow built these bytes? | Post-build tampering; starjacking; publishes from outside CI, such as a token stolen from a laptop | Malicious source; poisoned build inputs; code running inside the release job | A hosted CI with OIDC and a public repository |
| Reproducible build | Do these bytes follow from this commit? | Build-time injection; tarballs that diverge from source | Malicious source | A deterministic build plus independent rebuild infrastructure |
| SBOM (software bill of materials) | What components are inside? | Known-vulnerable components, by inventory | Behavior; anything the producer omits | Generation and storage |

The verdict: use package provenance as the default origin check for anything built in public CI, and pin the repository and signer workflow per package. Use [code signing](https://socket.dev/glossary/code-signing) where the build happens off public CI or must stay private. Use reproducible builds for packages critical enough to justify a rebuild, because only a rebuild connects the attested commit to the bytes you run. Treat a [software bill of materials](https://socket.dev/glossary/software-bill-of-materials) as an inventory, not an origin claim. None of the four tells you what the code does, which is why content analysis sits on top of all of them.

## Provenance as a policy signal

Provenance is most useful as a baseline that should not change. A package that has shipped from the same repository and workflow for years and suddenly publishes without provenance, or from a different workflow, deserves the scrutiny you would give a new maintainer. That pattern is the signature of a [maintainer account takeover](https://socket.dev/glossary/maintainer-account-takeover) with a stolen token: the attacker can publish, but not from the project's CI. pnpm's `trustPolicy: no-downgrade` setting checks this at install time, flagging a version whose publishing method is weaker than that of earlier releases, as we covered when [pnpm 11.5 added staged-publish support](https://socket.dev/blog/pnpm-11-5-adds-support-for-recognizing-npm-staged-publishes).

Requiring provenance everywhere is not yet practical. We checked the latest release of 30 widely used npm packages on November 3, 2026. 13 carried provenance and 14 were published through a trusted publisher. prettier@3.9.10 was trusted-published without provenance. lodash, typescript, eslint, @types/node, and 12 others had neither. Provenance also has hard prerequisites: npm requires a cloud-hosted runner and a public repository, so a package built on self-hosted infrastructure cannot have it. A missing attestation is a weak signal. A lost attestation is a strong one.

Registries now record two facts about every attested release: the commit that went into the build and the hash that came out. Neither npm nor PyPI checks that one produces the other. Until a registry rebuilds from the attested commit, or at least records whether an independent rebuild matched, package provenance tells you where to look. Reading what is there is still your job.
