Safeguard
Open Source Security

How to publish a secure Python package: signing, SBOMs, and trusted publishing

PyPI enforced two-factor authentication for all users on January 1, 2024 — but 2FA alone doesn't stop a stolen API token. Here's the full secure-publishing stack.

Safeguard Research Team
Research
7 min read

On January 1, 2024, the Python Package Index made two-factor authentication mandatory for every account that can upload a package — a response to a string of account-takeover incidents where stolen credentials let attackers push trojanized releases of trusted libraries. That was a necessary step, but 2FA only protects the login; it does nothing about the long-lived API tokens sitting in CI secrets, the unsigned wheel a user downloads, or the unpinned dependency that silently upgrades into a malicious version between builds. In April 2023, PyPI (with funding from Google's Open Source Security Team and engineering from Trail of Bits) shipped Trusted Publishing, replacing static tokens with short-lived OpenID Connect credentials issued per CI run. In November 2024, PyPI finalized PEP 740, letting the index store cryptographically verifiable Sigstore attestations alongside every release. Neither eliminates the need for the basics: reproducible builds, an accurate SBOM, and dependencies pinned by hash rather than by name and version alone. This guide walks through all four pieces — trusted publishing, attestation and signing, SBOM generation, and dependency pinning — as one workflow, not four separate tools bolted together.

What is PyPI Trusted Publishing and why does it replace API tokens?

Trusted Publishing replaces a long-lived PyPI API token stored as a CI secret with a short-lived OIDC token that PyPI verifies against a specific GitHub Actions workflow. You configure it on PyPI under a project's "Publishing" settings, specifying the GitHub repository owner, repo name, workflow filename, and optionally an environment name, before your first trusted-publish run. Your workflow requests permissions: id-token: write at the job level, GitHub's OIDC provider issues a token scoped to that exact repo and workflow, and PyPI exchanges it for a publishing credential that expires in minutes. According to PyPI's own announcement, the mechanism was funded by Google's Open Source Security Team with implementation work from Trail of Bits, specifically to remove the class of incidents where a leaked or phished token let an attacker publish a malicious release under a legitimate maintainer's name — the token simply doesn't exist between builds. TestPyPI supports the same flow with its own trusted-publisher configuration, so you can validate the whole pipeline against a throwaway index before it ever touches a real release.

How does the publish workflow actually look in CI?

A standard GitHub Actions setup splits into two jobs. The build job runs python -m build (from the pypa/build package) to produce an sdist and wheel into dist/, then uploads them as a workflow artifact — it never touches PyPI credentials at all. A separate publish job, ideally scoped to a GitHub Environment with a required reviewer, downloads that artifact and calls the pypa/gh-action-pypi-publish action, which needs no username, password, or token field under Trusted Publishing — it negotiates the OIDC exchange itself. Splitting build from publish matters for a reason beyond tidiness: it guarantees the exact bytes a human or CI reviewer inspected are the exact bytes that get signed and uploaded, with no rebuild step in between that could introduce drift or a compromised dependency. Safeguard's own attestation pipeline follows the same shape for container and package artifacts — a safeguard/attest@v2 step runs after build, binding the SBOM, provenance, and vulnerability-scan predicates to the artifact digest that was actually produced, not to a re-derived one.

What changed with PEP 740 and Sigstore-based package signing?

PEP 740, finalized on PyPI in November 2024, lets the index accept and serve cryptographically verifiable attestations alongside a package file, built on the same Sigstore infrastructure — Fulcio for short-lived signing certificates bound to an OIDC identity, and the Rekor transparency log for public inclusion proof — that replaced traditional PGP signing (which PyPI had already begun deprecating and removing). As of October 29, 2024, attestations became the default output of the official pypa/gh-action-pypi-publish action for anyone using Trusted Publishing, and PyPI's blog put the number of packages capable of attesting to their own provenance by default at roughly 20,000. Adoption of the older releases lags: research from Trail of Bits, published alongside the PEP 740 rollout, found only about 5% of the 360 most-downloaded PyPI packages had attestations at launch, since PEP 740 only covers newly published releases going forward, not a project's back catalog. The identity being verified is the GitHub Actions workflow itself, not a personal key a maintainer could lose or have stolen — the same trust model Safeguard uses for its own Sigstore-backed artifact signing, where Fulcio certificates are bound to GitHub, GitLab, or cloud workload OIDC identity rather than a long-lived private key.

How do you generate an SBOM for a Python package, and in what format?

A Software Bill of Materials for a Python project lists every direct and transitive dependency with its resolved version, in either CycloneDX or SPDX format, and should be generated from the same lockfile that actually gets installed — not from pyproject.toml's loose version ranges. The cyclonedx-bom Python tool (maintained under the CycloneDX project) reads requirements.txt, poetry.lock, or a Pip environment directly and emits a CycloneDX 1.5+ document; syft from Anchore does the same by scanning an installed environment or container layer. The point of generating it at build time, not on demand, is that when a new CVE lands in a transitive dependency you didn't know you had, you query the SBOM instead of re-resolving the whole dependency tree under time pressure. Safeguard's SBOM generation works the same way operationally: it ingests manifests including requirements.txt directly through its Upload Manifest File integration or from a connected source repository, and performs continuous daily scanning against newly disclosed CVEs so a project's SBOM stays current without a manual re-scan every time you want an answer.

Why does hash-based dependency pinning matter more than version pinning?

Pinning requests==2.31.0 in requirements.txt only constrains the version string — it does nothing to stop pip from installing a different set of bytes if that version is ever re-uploaded, or from resolving a same-named-but-malicious package from an unintended index, the exact mechanism behind the 2021 dependency-confusion research that showed internal package names could be shadowed by public uploads. Hash pinning closes that gap: pip install --require-hashes -r requirements.txt refuses to install anything whose SHA-256 doesn't match the hash recorded in the file, which pip-compile (from pip-tools) or Poetry's lockfile generate automatically alongside every pinned version. Combined with Trusted Publishing on the producer side, hash pinning on the consumer side closes the loop: a maintainer's release is signed and provenance-attested at the point of publish, and every downstream install verifies the exact bytes it's pulling match what was attested, with no window for a compromised mirror or a re-uploaded file to substitute something else.

How Safeguard fits into a secure Python publishing pipeline

Safeguard doesn't replace PyPI's own Trusted Publishing or PEP 740 attestation flow — it extends the same trust model past the point where a package leaves the index. Sigstore-based signing with Fulcio-issued, OIDC-bound certificates and Rekor transparency logging works identically whether the artifact is a Python wheel, a container image, or an npm package, and Safeguard's safeguard/attest@v2 CI step produces SLSA provenance, CycloneDX and SPDX SBOM, and vulnerability-scan attestations in the same in-toto format PEP 740 uses for package attestations, reaching SLSA Level 2 out of the box on standard GitHub Actions runners and Level 3 when the build runs as a GitHub reusable workflow. On the consumption side, Safeguard generates CycloneDX SBOMs directly from a project's requirements.txt or lockfile and runs continuous daily scanning, so a team publishing a signed, hash-pinned, trusted-published Python package can also verify — with the same tooling — that everything it depends on was published just as carefully.

Never miss an update

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