Since November 2024, PyPI has accepted digital attestations (PEP 740): a signed statement, made in the publisher's CI job, that ties each wheel and sdist to the repository and workflow that built it. They're easy to ignore, because pip doesn't verify them at install time. On 14 October 2026 we checked the 1,000 most-downloaded projects on PyPI, and the latest release of 386 of them (38.6%) carries attestations, including 59 of the top 100.
This guide is for Python teams who want to use those attestations. It covers what an attestation proves and what it doesn't, how to check one with the official tool, a short script that inventories your pinned dependencies and flags a change of publisher, and the data on who attests today.
The short answer
A PyPI attestation proves that a specific file was published, unmodified, by a specific Trusted Publisher identity, such as the publish.yml workflow in github.com/psf/requests. It does not prove that the code is safe. To use one, you check the file against the identity you expect: pypi-attestations verify pypi --repository https://github.com/psf/requests pypi:<filename>. The most useful habit is to record each dependency's publisher once, then alert when a new release comes from a different identity or loses its attestations. PyPI's own security model describes exactly that use.
What an attestation is
Under PEP 740, a project that publishes through Trusted Publishing can upload signed attestations with each file. The signing uses Sigstore's "keyless" model: the CI job's OIDC identity gets a short-lived certificate, signs the file's digest, and the signature is recorded in Sigstore's public transparency log. PyPI checks the attestation at upload and serves it back through its Integrity API.
PyPI's documentation lists three supported publishers: GitHub Actions, GitLab CI/CD and Google Cloud. On GitHub, maintainers usually don't do anything: the official pypa/gh-action-pypi-publish action generates and uploads attestations by default for any project using Trusted Publishing. That default explains most of the adoption below. Our earlier post on PyPI's attestation roadmap covers the policy side.
There are two limits. PyPI's security model page puts the first one bluntly: an attestation tells you where a package came from, "but not whether you should trust it". If an attacker gets malicious code into the repository, or can trigger the publishing workflow, the release is still validly attested. The second is that attestations only exist for projects that publish from CI with Trusted Publishing. A maintainer uploading from a laptop with an API token can't produce one.
What attestations do give you is protection against a file being changed after it was built, in the index, a mirror or a proxy, and a way to notice when a project's publisher changes. A new publisher identity, or a release with no attestation from a project that always had one, is the signal you'd expect if control of a package changed hands.
Who attests today: the top 1,000 packages
We took the 1,000 most-downloaded PyPI projects from Hugo van Kemenade's top-pypi-packages dataset (30 days to 1 October 2026) and checked every file of each project's latest release through PyPI's JSON and simple APIs on 14 October 2026.
| Group | Latest releases with attestations |
|---|---|
| Top 1,000 projects | 386 of 1,000 (38.6%) |
| Top 100 projects | 59 of 100 |
| Latest release uploaded after 14 July 2026 | 277 of 581 (47.7%) |
| Latest release uploaded in the first half of 2026 | 60 of 147 (40.8%) |
| Latest release uploaded in 2025 | 47 of 129 (36.4%) |
| Latest release uploaded before 2025 | 2 of 143 (1.4%) |
Of the 386 attested projects, 355 publish from GitHub Actions, 30 from Google Cloud (mostly Google's own client libraries), and one from GitLab. Three releases were only partly attested: in SQLAlchemy 2.1.4, for example, 64 of 72 files carry attestations, but the sdist and the Windows ARM64 wheels don't.
The pattern is what you'd expect from an opt-out default. Actively maintained projects that moved to Trusted Publishing get attestations without trying, and projects whose latest release predates November 2024 can't have them. Several of the most-downloaded projects, including boto3, botocore, setuptools, PyYAML and six, had no attestations on their latest release. That doesn't mean anything is wrong with them, only that you can't use this check for them.
Here's the survey script, which needs only the standard library:
#!/usr/bin/env python3
"""How many of the top N PyPI projects attest their latest release? (PEP 740)
Input: top-pypi-packages.min.json from https://hugovk.dev/top-pypi-packages/"""
import json, sys, urllib.request
from concurrent.futures import ThreadPoolExecutor
def get(url, accept=None):
req = urllib.request.Request(url, headers={"Accept": accept} if accept else {})
with urllib.request.urlopen(req, timeout=60) as r:
return json.load(r)
def check(project):
latest = get(f"https://pypi.org/pypi/{project}/json")
names = {u["filename"] for u in latest["urls"]}
index = get(f"https://pypi.org/simple/{project}/", "application/vnd.pypi.simple.v1+json")
files = [f for f in index["files"] if f["filename"] in names]
return project, len(files), sum(1 for f in files if f.get("provenance"))
n = int(sys.argv[2]) if len(sys.argv) > 2 else 1000
top = [row["project"] for row in json.load(open(sys.argv[1]))["rows"][:n]]
with ThreadPoolExecutor(16) as pool:
results = list(pool.map(check, top))
attested = [p for p, total, a in results if a]
print(f"{len(attested)} of {len(results)} latest releases carry attestations "
f"({100 * len(attested) / len(results):.1f}%)")
print(f"top 100: {sum(1 for p, t, a in results[:100] if a)} of 100")
$ python3 survey.py top-pypi-packages.min.json 1000
386 of 1000 latest releases carry attestations (38.6%)
top 100: 59 of 100
Verify one file with pypi-attestations
The reference verifier is the pypi-attestations CLI, maintained under the PyPI organisation. It downloads the file and its provenance, checks that the signing identity matches the repository you name, and verifies the signature. We used version 0.0.30:
$ pip install pypi-attestations
$ pypi-attestations verify pypi --repository https://github.com/psf/requests pypi:requests-2.34.2-py3-none-any.whl
OK: requests-2.34.2-py3-none-any.whl
$ echo $?
0
$ pypi-attestations verify pypi --repository https://github.com/someone/requests pypi:requests-2.34.2-py3-none-any.whl
Verification failed: provenance was signed by repository "psf/requests", expected "someone/requests"
$ echo $?
1
The second command shows the important part. You have to tell the tool which identity you expect, and it fails, with exit code 1, when the file was signed by anyone else. A signature on its own only proves that somebody signed. For projects that publish from Google Cloud, pass --gcp-service-account with the expected service account email instead of --repository.
You can also verify a file you've already downloaded by passing its local path instead of pypi:<filename>, which is useful in a build that fetches wheels into a cache or an internal mirror first.
Inventory your dependencies and catch a publisher change
Checking one file at a time doesn't scale, and you need to know which identity to expect for each dependency. This script reads the name==version pins from a requirements file, reports how many files of each pinned release are attested and by whom, and keeps a baseline of publishers. On later runs it flags any dependency whose publisher changed or whose attestations disappeared, and exits non-zero so CI can fail on it. It uses only the standard library.
#!/usr/bin/env python3
"""Report which pinned PyPI dependencies have PEP 740 attestations, who published them,
and whether that changed since the last run. Standard library only.
Usage: python3 pypi_provenance.py requirements.txt [--baseline publishers.json]"""
import json, re, sys, urllib.request
def get(url, accept=None):
req = urllib.request.Request(url, headers={"Accept": accept} if accept else {})
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
def pins(path):
for line in open(path):
m = re.match(r"\s*([A-Za-z0-9._-]+)\s*==\s*([^\s;#]+)", line)
if m:
yield m.group(1).lower(), m.group(2)
def provenance(name, version):
release = get(f"https://pypi.org/pypi/{name}/{version}/json")
wanted = {u["filename"] for u in release["urls"]}
index = get(f"https://pypi.org/simple/{name}/", "application/vnd.pypi.simple.v1+json")
files = [f for f in index["files"] if f["filename"] in wanted]
publishers = set()
for f in files:
if f.get("provenance"):
for bundle in get(f["provenance"])["attestation_bundles"]:
p = bundle["publisher"]
publishers.add(f"{p['kind']}:{p.get('repository') or p.get('email') or '?'}")
attested = sum(1 for f in files if f.get("provenance"))
return len(files), attested, sorted(publishers)
args = sys.argv[1:]
baseline_path = args[args.index("--baseline") + 1] if "--baseline" in args else None
baseline = {}
if baseline_path:
try:
baseline = json.load(open(baseline_path))
except FileNotFoundError:
pass
current, problems = {}, 0
for name, version in pins(args[0]):
total, attested, pubs = provenance(name, version)
current[name] = pubs
status = "attested" if attested == total and total else ("PARTIAL" if attested else "none")
note = ""
if name in baseline and baseline[name] and baseline[name] != pubs:
note = f" <-- CHANGED, was {', '.join(baseline[name])}" if pubs else " <-- LOST attestations"
problems += 1
print(f"{name + '==' + version:<34} {attested:>2}/{total:<2} {status:<9} {', '.join(pubs) or '-'}{note}")
if baseline_path:
json.dump({**baseline, **current}, open(baseline_path, "w"), indent=2, sort_keys=True)
sys.exit(1 if problems else 0)
First run against a sample set of pins, which writes the baseline:
$ python3 pypi_provenance.py requirements.txt --baseline publishers.json
requests==2.34.2 2/2 attested GitHub:psf/requests
urllib3==2.8.0 2/2 attested GitHub:urllib3/urllib3
packaging==26.3 2/2 attested GitHub:pypa/packaging
sqlalchemy==2.1.4 64/72 PARTIAL GitHub:sqlalchemy/sqlalchemy
jeepney==0.9.0 2/2 attested GitLab:takluyver/jeepney
boto3==1.43.113 0/2 none -
googleapis-common-protos==1.75.5 2/2 attested Google:google-cloud-sdk-py@oss-exit-gate-prod.iam.gserviceaccount.com
To test the alert, we edited the baseline so it expected urllib3 from a different repository, then ran it again:
requests==2.34.2 2/2 attested GitHub:psf/requests
urllib3==2.8.0 2/2 attested GitHub:urllib3/urllib3 <-- CHANGED, was GitHub:urllib3-maintainer/urllib3
packaging==26.3 2/2 attested GitHub:pypa/packaging
sqlalchemy==2.1.4 64/72 PARTIAL GitHub:sqlalchemy/sqlalchemy
jeepney==0.9.0 2/2 attested GitLab:takluyver/jeepney
boto3==1.43.113 0/2 none -
googleapis-common-protos==1.75.5 2/2 attested Google:google-cloud-sdk-py@oss-exit-gate-prod.iam.gserviceaccount.com
On a real change you'd investigate before upgrading: read the project's announcements, check that the new repository belongs to the same maintainers, and look at the release diff. Projects do move repositories or switch CI providers for legitimate reasons, so treat a change as a question to answer, not proof of compromise.
Putting it into a pipeline
A practical setup looks like this:
- Run the inventory on every lockfile change. Commit
publishers.jsonnext to your requirements, so a publisher change shows up in code review rather than in production. - Verify files from any mirror or cache. If you install from an internal proxy, run
pypi-attestations verify pypion the cached files, with--repositoryfrom your baseline, for the dependencies that matter most. That's the case attestations protect best: a file changed after it was built. - Treat "none" as a fact, not a failure. Most projects without attestations aren't risky, they just don't publish through Trusted Publishing. Keep other controls for them, such as hash-pinned installs (
pip install --require-hashes) and a short cooldown before adopting brand-new releases. - Ask your critical dependencies to attest. For a project already on GitHub Actions, it's usually a switch to Trusted Publishing plus the official publish action. An issue with a link to PyPI's guide is a small, useful contribution.
Provenance is one signal among several. To see a dependency's known vulnerabilities and malware history next to it, you can look the package up in Safeguard's free Gold directory.
The fair view
Attestations are a modest control. They don't catch a maintainer account takeover that happens through the repository, they don't cover the 61% of top projects that don't publish them, and nothing installs or rejects packages based on them by default. pip's changelog, through version 26.2.1, has no attestation verification feature. So today, checking them is a step you add yourself.
Still, it's cheap. The data is public, the verifier is one pip install away, and a publisher change on a package you depend on is a signal that's hard to get any other way. With nearly half of recently released top packages already attesting, the record is complete enough to be worth watching.