Safeguard
Open Source Security•

How to Verify PyPI Attestations (PEP 740), and Which Top Packages Have Them

PyPI has accepted signed attestations since November 2024, but pip doesn't check them. How to verify one, how to catch a change of publisher in CI, and which of the top 1,000 packages attest today.

Safeguard Security Team
Security Research
10 min read

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.

GroupLatest releases with attestations
Top 1,000 projects386 of 1,000 (38.6%)
Top 100 projects59 of 100
Latest release uploaded after 14 July 2026277 of 581 (47.7%)
Latest release uploaded in the first half of 202660 of 147 (40.8%)
Latest release uploaded in 202547 of 129 (36.4%)
Latest release uploaded before 20252 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:

  1. Run the inventory on every lockfile change. Commit publishers.json next to your requirements, so a publisher change shows up in code review rather than in production.
  2. Verify files from any mirror or cache. If you install from an internal proxy, run pypi-attestations verify pypi on the cached files, with --repository from your baseline, for the dependencies that matter most. That's the case attestations protect best: a file changed after it was built.
  3. 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.
  4. 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.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.