Back to glossary

twine (Python)

twine is the PyPA command-line tool that uploads built Python packages to PyPI, authenticating with an API token or a short-lived Trusted Publishing token.

Also known as twine upload, twine check, pypa/twine, python twine, PyPI upload tool

twine is the Python Packaging Authority's command-line tool for uploading built wheels and source distributions to PyPI or any index that speaks the same upload API. It reads metadata from each file, POSTs file and metadata in one multipart request, and authenticates with an API token or a Trusted Publishing token. Whoever holds that credential can publish.

Most people meet twine as twine upload dist/*, and many more run it without knowing: the official pypa/gh-action-pypi-publish GitHub Action is a wrapper that ends in exec twine upload. A twine upload proves possession of a credential. It says nothing about which commit, which machine, or which build produced the bytes. Our position is that twine is a small, correct client and the risk lives in the credential you hand it. If you publish from CI, stop handing it a long-lived token.

What is twine in Python?

#

twine is a thin HTTP client for PyPI's legacy upload API, maintained by the PyPA in the pypa/twine repository. It does not build anything. A build backend such as setuptools or hatchling produces the wheel and sdist, and twine ships those exact files. The twine documentation states the design goal in one line: "The goal of Twine is to improve PyPI interaction by improving security and testability."

twine has three subcommands. twine upload sends distributions to an index. twine check validates metadata and confirms the long description will render on PyPI. twine register pre-registers a project name, which PyPI does not support and only some private indexes need.

As of October 2026, the current release is twine 7.0.0, published July 27, 2026. The security-relevant changes over the last three years are spread across the changelog, so we collected them in one place:

VersionReleasedSecurity-relevant change
5.0.0Feb 2024Rejects any username other than __token__ for PyPI and TestPyPI
5.1.0May 2024Adds the experimental --attestations flag
6.0.0Nov 2024Defaults the PyPI username to __token__
6.1.0Jan 2025Built-in Trusted Publishing; drops egg and wininst uploads
6.2.0Sep 2025Refreshes Trusted Publishing tokens on long uploads; removes MD5 digests; stops config parse errors from printing secrets
7.0.0Jul 2026Supports metadata version 2.5 and rejects the never-standardized 2.0

The official GitHub Action pins twine. At tag v1.14.3, pypa/gh-action-pypi-publish installs twine==7.0.0, runs twine check, and then runs exec twine upload in its upload script. Everything below about twine's behavior applies to that Action too.

How do you upload a package to PyPI with twine?

#

You upload a package to PyPI with twine in three steps: build the distributions, check them, and upload them.

Bash
$ python -m build
Successfully built acme_widget-1.0.0.tar.gz and acme_widget-1.0.0-py3-none-any.whl
$ twine check dist/*
Checking dist/acme_widget-1.0.0-py3-none-any.whl: PASSED
Checking dist/acme_widget-1.0.0.tar.gz: PASSED
$ twine upload dist/*

twine upload -r testpypi dist/* sends the same files to TestPyPI first if you want a rehearsal. On PyPI the username is always the literal string __token__ and the password is an API token that starts with pypi-.

What twine actually sends

#

To see the wire format, we pointed twine 7.0.0 at a local HTTP server and logged the request. This is the entire upload of one wheel, with a fake token:

Plain text
POST /legacy/
Authorization: Basic X190b2tlbl9fOnB5cGktQWdFSWNIbHdhUzV2Y21jRkFLRQ==
Content-Type: multipart/form-data; boundary=71c3dc7c1ba14ad99327a92ee602620e
User-Agent: twine/7.0.0 CPython/3.13.18
  license_expression = MIT
  name = acme-widget
  summary = Demo package
  description_content_type = text/markdown
  metadata_version = 2.5
  version = 1.0.0
  requires_python = >=3.9
  description = # acme-widget
  pyversion = py3
  filetype = bdist_wheel
  sha256_digest = c4d9cb483ca59cae5d5eb81c85a970416f3a5109bd959c36b30d53294dbe3aee
  blake2_256_digest = 4adeb56d33441589af2bc9198ccf89c8c0b272dac1a00253c8bf4ae92cef7e14
  :action = file_upload
  protocol_version = 1
  content = <1041 bytes, filename=acme_widget-1.0.0-py3-none-any.whl>

Three details in that request matter for security:

  • The Authorization header is HTTP Basic auth: base64 of __token__:pypi-.... The token is a bearer secret. Anyone who reads it from a log, a CI variable, or a process environment can replay it from anywhere.
  • Every metadata field comes from the file itself. twine parses the wheel's METADATA and sends it as form fields next to the bytes and their SHA-256 and BLAKE2b-256 digests, matching the fields in PyPI's upload API documentation.
  • Nothing in the request identifies the source. There is no repository, commit, or builder unless the upload carries PyPI attestations, which currently require Trusted Publishing on PyPI.

twine's credential resolution order

#

twine resolves the upload password in a fixed order and stops at the first source that returns a value. We read the order out of twine/auth.py and twine/utils.py in 7.0.0:

In text form, the order is:

  1. The -p flag or the TWINE_PASSWORD environment variable.
  2. The password key for that repository in ~/.pypirc.
  3. The system keyring, through the keyring library.
  4. Trusted Publishing, only when the target is PyPI or TestPyPI and the username is __token__.
  5. An interactive prompt for an API token, which fails under --non-interactive.

Steps 1 to 3 return long-lived tokens. Step 4 returns a token that expires 15 minutes after it is minted.

Trusted Publishing is step 4, not step 1. A leftover TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} line in a workflow means twine publishes with the long-lived token even after you register a Trusted Publisher, and nothing fails. The only trace is an INFO line that appears with --verbose:

Plain text
$ TWINE_PASSWORD=pypi-... twine upload --verbose dist/*.whl
INFO     username set by command options
INFO     password set by command options
INFO     username: __token__
INFO     password: <hidden>

A token that outlives the move to Trusted Publishing is exactly what the second half of the Ultralytics compromise used in December 2024. PyPI's attack analysis says versions 8.3.45 and 8.3.46 came from an unrevoked PyPI API token, with no matching source repository activity and no publish attestations. The project had already adopted Trusted Publishing. The old token was still valid.

On a developer machine, the keyring is the least bad of the three stored options. ~/.pypirc is a plaintext INI file, and stealers know where it lives: the infostealer in the Hades wave of the Miasma campaign lists .pypirc and PyPI tokens among its targets.

What can a PyPI API token do?

#

A PyPI API token can do whatever its caveats allow, and whoever holds it can narrow those caveats offline without asking PyPI. PyPI tokens are macaroons. Warehouse, the codebase behind PyPI, mints each one with pymacaroons in version 2 format and prefixes the serialized bytes with pypi-. The first bytes encode the format version and the location pypi.org, which is why every PyPI token begins pypi-AgEIcHlwaS5vcmc. That fixed prefix makes leaked tokens easy to find. PyPI's secret reporting API lets partners such as GitHub report exposed tokens so they can be revoked.

Each caveat is a JSON array whose first element is a tag. The tags, from Warehouse's macaroons/caveats module:

TagCaveatSerialized formMeaning
0Expiration[0, expires_at, not_before]Valid only inside this time window
1ProjectName[1, [names...]]Valid only for these normalized project names
2ProjectID[2, [ids...]]Valid only for these project IDs
3RequestUser[3, user_id]A user-scoped token, valid for every project that user can upload to
4OIDCPublisher[4, publisher_id]A Trusted Publishing token, valid only for that publisher's projects

A user-scoped token is the dangerous option. PyPI's publishing guide lets a token cover a whole account or one project, and recommends scoping CI tokens to as few projects as possible. A leaked user-scoped token can publish to every project its owner maintains.

The macaroon construction gives token holders a capability most people never use. Every caveat is chained into the signature: PyPI computes an HMAC over the identifier with its secret key, then each caveat is HMACed with the previous signature as the key. The token ships only the final signature.

In text form: PyPI mints the identifier and signs it with its secret key to produce the first signature, then appends the RequestUser caveat and signs again. The holder can append a ProjectName caveat and an Expiration caveat, each signed with the previous signature, without any secret from PyPI. The holder cannot remove a caveat, because that requires an earlier signature the token no longer carries.

We reproduced this with pymacaroons and a mock key, using Warehouse's exact caveat encoding:

Python
import pymacaroons, json, time

def caveat(c): return json.dumps(c, separators=(",", ":"))

key = b"server-side-secret-held-only-by-pypi"   # mock key
m = pymacaroons.Macaroon(location="pypi.org", identifier="00000000-0000-0000-0000-000000000001",
                         key=key, version=pymacaroons.MACAROON_V2)
m.add_first_party_caveat(caveat([3, "6f1e2c0a-0000-4000-8000-00000000beef"]))
user_token = "pypi-" + m.serialize()

# Holder side: no key, no network call
m2 = pymacaroons.Macaroon.deserialize(user_token[5:])
now = int(time.time())
m2.add_first_party_caveat(caveat([1, ["acme-widget"]]))
m2.add_first_party_caveat(caveat([0, now + 900, now]))
narrow_token = "pypi-" + m2.serialize()
Plain text
issued:    pypi-AgEIcHlwaS5vcmcCJDAwMDAwMDAwLTAwMDAtMDAwMC0wMDAwLTAwMDA... 179 chars
narrowed:  pypi-AgEIcHlwaS5vcmcCJDAwMDAwMDAwLTAwMDAtMDAwMC0wMDAwLTAwMDA... 245 chars
  caveat b'[3,"6f1e2c0a-0000-4000-8000-00000000beef"]'
  caveat b'[1,["acme-widget"]]'
  caveat b'[0,1793293160,1793292260]'
signature valid: True

Warehouse expects this. A docstring in its caveat module says users can attenuate the macaroons PyPI issues "at any time without telling us," and the server verifies every embedded caveat. In practice, you can hand a CI job a 15-minute, single-project copy of a broader token. The trade-off is that attenuation only protects the narrowed copy. The original token still exists, and if it leaks, the narrowed copies do not help. Revocation remains the only fix for a leaked parent.

Trusted Publishing uses the same object. PyPI's Trusted Publishing documentation says the minted token "behaves exactly like a normal project-scoped API token, except that it's only valid for 15 minutes from time of creation." It carries an OIDCPublisher caveat instead of a RequestUser caveat.

Where PyPI publishing tokens leak

#

Every incident in the table below ends the same way: a valid credential reaches the upload endpoint. The differences are in how the attacker got it.

WhenIncidentHow publishing access was obtainedWhat reached PyPI
Dec 2024UltralyticsPoisoned GitHub Actions cache, then an unrevoked API token8.3.41 and 8.3.42 through the real workflow; 8.3.45 and 8.3.46 through the token, with no attestations
Sep 2025GhostActionInjected workflows sent PyPI tokens stored as GitHub secrets to attacker serversNothing; PyPI invalidated the tokens
Mar 2026LiteLLM and TelnyxAPI token exposed through the compromised Trivy dependencyMalicious releases of both; Telnyx 4.87.1 and 4.87.2 had no matching tag or commit
Oct 2026GhostAction waveStolen maintainer accounts committed a workflow that read PYPI_USERNAME and PYPI_PASSWORDNone observed at time of reporting
Oct 2026LiteLLM 1.107.0The project's compromised release pipeline, building from a lookalike source repositorySeven malicious wheels; the matching container image carried build provenance

GhostAction is the cleanest illustration of the token problem. In September 2025, PyPI reported that attackers had injected workflows into many repositories to exfiltrate PyPI tokens stored as GitHub secrets. PyPI invalidated them and found no malicious uploads. The October 2026 wave hit kitao/pyxel, where the injected file read secrets named PYPI_PASSWORD and PYPI_USERNAME. Those are the names a username-and-password publish step would use.

PyPI's April 2026 incident report traced the March 2026 LiteLLM releases and the Telnyx compromise to "an API token exposure from an exploited Trivy dependency," part of the Trivy GitHub Actions compromise attributed to TeamPCP. In the Telnyx case, neither malicious version had a corresponding tag, release, or commit in the official repository. That gap between the registry and the source is what a long-lived token allows: the holder can upload from anywhere, and the registry cannot tell the difference.

Incidents like these are maintainer account takeovers in effect, even when no password was phished. Possession of the token is the account, as far as the upload endpoint is concerned.

Does Trusted Publishing make twine uploads safe?

#

Trusted Publishing makes a twine upload safer by removing the stored secret. It does not prove the uploaded code is safe. When twine 6.1 or later reaches step 4, it asks the upload host (upload.pypi.org for PyPI) for its OIDC audience at /_/oidc/audience, gets an identity token from the CI platform through the id library, and posts that token to /_/oidc/mint-token. The id library can detect identity tokens on GitHub Actions, GitLab CI, Google Cloud, Buildkite, and CircleCI, but PyPI must also support the provider as a Trusted Publisher. The same Warehouse endpoints answer on pypi.org:

Plain text
$ curl -s https://pypi.org/_/oidc/audience
{"audience":"pypi"}
$ curl -s -X POST https://pypi.org/_/oidc/mint-token -H 'content-type: application/json' -d '{"token":"not-a-jwt"}'
{"errors":[{"code":"invalid-payload","description":"malformed JWT"}],"message":"Token request failed"}

With a valid identity token from a registered workflow, PyPI returns an API token that expires 15 minutes later. twine 6.2.0 added a refresh: after 10 minutes, it mints a new token on each request so long uploads do not fail. uv publish goes further and tries to revoke the short-lived token after publishing. npm's trusted publishing launch in 2025 copied the same design, and adoption on PyPI is real: about a quarter of files uploaded in October 2025 used it, across more than 45,000 projects, according to PyPI's figures.

The limit is the workflow. PyPI's security model puts it bluntly: "treat your Trusted Publishers as if they are API tokens." Anyone who can change the publishing workflow, or the code it runs, can publish. Ultralytics 8.3.41 and 8.3.42 went out through the project's real workflow after cache poisoning, with signed provenance. The LiteLLM 1.107.0 compromise came out of the project's compromised release pipeline, and the container image's build provenance recorded the source repository as Berri-AI/litellm, one hyphen away from the legitimate BerriAI/litellm. We later found that secret-stealing workflows ran in the repository 11 days before the release. Package provenance is necessary, but it records the path an artifact took, not whether the path was trustworthy.

What does reduce the risk is a narrow publish job. PyPI recommends a dedicated GitHub environment with required reviewers, id-token: write granted only to the publishing job, tag protection, and a job with two steps: download the built files, then publish. In our view, the workflow should look like this, with every action pinned to a commit SHA:

YAML
jobs:
  publish:
    runs-on: ubuntu-latest
    needs: build
    environment: pypi            # require reviewers on this environment
    permissions:
      id-token: write            # only this job can mint a PyPI token
    steps:
      - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
        with:
          name: dist
          path: dist/
      - uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3

No password: input appears, so the Action performs the OIDC exchange itself and passes the 15-minute token to twine as TWINE_PASSWORD. That makes the Action more explicit than bare twine: an empty password input means Trusted Publishing, and a username and password aimed at pypi.org fails the job. The Action also generates PEP 740 attestations with Sigstore by default when Trusted Publishing is in use. twine alone does not create attestations; its --attestations flag only uploads ones you already made. Then delete every API token on the project, so a stolen token cannot route around the reviewed workflow. For broader workflow hardening, see GitHub Actions security.

One flag to retire: twine upload --sign. PyPI stopped using PGP signatures in May 2023 and silently ignores them on upload. Its audit found only about 36% of the signing keys could be meaningfully verified.

twine vs uv publish vs pypa/gh-action-pypi-publish

#

All three tools POST to the same legacy upload endpoint. They differ in which credential paths they make easy and whether they produce provenance.

twine 7.0.0uv publish (0.13)pypa/gh-action-pypi-publish (v1.14.3)
Trusted PublishingAutomatic since 6.1.0, only if no password was found firstAutomatic in supported CI when no credentials are setThe intended mode; used when no password input is set
Long-lived token input-p, TWINE_PASSWORD, .pypirc, keyring--token, UV_PUBLISH_TOKEN, username and passwordpassword input
Generates attestationsNoNoYes, by default with Trusted Publishing
Uploads attestationsWith --attestations, from existing filesBy default, if matching files existYes
Metadata checkSeparate twine check commandNo separate check commandRuns twine check before upload
Short-lived token handlingRefreshes after 10 minutes, does not revokeAttempts revocation after publishMints one token and passes it to twine

Verdict: on GitHub Actions, use pypa/gh-action-pypi-publish with Trusted Publishing and no tokens on the project, because it is the only option that produces attestations without extra steps. On other CI providers that PyPI supports as Trusted Publishers, such as GitLab CI or Google Cloud, use twine 6.1 or later or uv publish with Trusted Publishing, and add a separate attestation step if consumers will verify provenance. Use twine with a stored token only for private indexes and one-off manual releases, keep that token project-scoped in the keyring, and revoke it afterward.

What the upload API still cannot express

#

The legacy upload API has no concept of a release. PyPI's upload API documentation says it directly: "Releases on PyPI are created by uploading one file at a time." There is no staging, no atomic publish, and no way for a project to require that uploads come only from its Trusted Publisher. As of October 2026, PEP 694, the Upload 2.0 API that would add staged releases, has been in draft since June 2022.

PyPI has been closing gaps around the edges. As of July 2026, PyPI rejects new files uploaded to releases older than 14 days, so a stolen token can no longer slip a malicious wheel into an old, trusted version. What we think should come next is a per-project switch that rejects API-token uploads once Trusted Publishing is configured. After Ultralytics, Warehouse maintainers discussed automatically revoking unused tokens in issue #17260. As of October 2026, we could not find a shipped setting that makes a project Trusted-Publishing-only.

Until then, the registry answers one question: is this token allowed to upload to this project? It does not answer the question that matters to everyone installing the package: what does this file do? LiteLLM 1.107.0 came through a legitimate publishing path with build provenance attached. Our scanner flagged its seven wheels seven minutes after publication, based on what the files contained. twine will upload whatever you give it with whatever token you give it, so the last reliable check happens on the bytes, not the credential.