Trusted Publishing to npm and PyPI with OIDC
Problem
Publishing to npm or PyPI has historically required a single point of failure: a long-lived API token generated by a human account, stored as a CI secret, and used verbatim in every publish run. When that token leaks — through a misconfigured log, a compromised developer laptop, a malicious dependency reading environment variables, or a GitHub Actions runner compromise — the attacker gains the ability to publish a new version of your package to millions of downstream consumers. The token does not expire on its own. The attacker can wait, move quietly, and inject a backdoor in a patch release where reviewers have lowered their guard.
The consequences of this model have been well documented through a series of high-profile incidents. The event-stream incident in 2018 saw a maintainer hand over a popular npm package to an unknown contributor who subsequently published a backdoored version targeting Bitcoin wallet software. The ua-parser-js package was compromised in 2021 when the maintainer’s npm account credentials were stolen and used to publish three backdoored versions in quick succession, each containing a cryptominer and a password stealer. The colors and faker incidents in 2022, while deliberate sabotage rather than credential theft, demonstrated that a single compromised or malicious publish action against a widely depended-upon package can break thousands of downstream projects simultaneously. In each case, the attacker’s path to the registry was through a set of credentials that a human generated and never had a systematic mechanism to rotate or scope.
PyPI addressed this problem in 2023 by introducing Trusted Publishing, a mechanism built on OpenID Connect (OIDC). npm followed with provenance and OIDC-backed publishing in 2024. Both registries now allow package owners to configure a specific GitHub Actions workflow as an authorized publisher. When the workflow runs, it requests a short-lived OIDC identity token from GitHub, presents it to the registry, and the registry issues a temporary upload credential scoped to that specific package and workflow run. No secret is stored in GitHub. No token needs to be rotated. The credential that reaches the registry is valid for minutes, not months, and cannot be reused after the publish step completes.
Despite these mechanisms existing, adoption across the open source ecosystem has been slow. The majority of actively maintained npm and PyPI packages in the wild still rely on NPM_TOKEN or PYPI_TOKEN secrets stored in their repository settings. Every such project remains one compromised runner or one malicious transitive build dependency away from a supply chain incident. The secret is available in the environment during the build step — the same step that executes npm install or pip install — and any code running during that install can read and exfiltrate it before the publish step begins.
The trusted publishing ecosystem itself has had underpublicized security issues that maintainers and platform engineers need to track actively. The canonical GitHub Action for publishing to PyPI, pypa/gh-action-pypi-publish, had a vulnerability in which the OIDC token exchange could be triggered by a fork pull request if the workflow was configured with on: pull_request as a trigger rather than restricting to push events or tags. A fork PR workflow runs with a reduced permission set, but the OIDC exchange logic in older Action versions did not validate the triggering event context strictly enough, meaning a contributor who opened a PR from a fork could potentially obtain a publish token for the target package. The security fix was shipped as a new release of the Action. No CVE was filed against the Action itself — the fix appeared as a code change in a public pull request on the pypa/gh-action-pypi-publish repository, visible to anyone watching the project, for several days before a new release was cut and the fix was distributed to users who pin to @release/v1. Anyone who understood the patch and the window of exposure could have crafted an exploit targeting projects that had not yet upgraded.
npm’s OIDC implementation had a separate scope boundary issue during its initial rollout. A workflow authorized to publish one package under an npm organization could, under specific conditions, obtain a token that allowed publishing to a different package in the same organization. This issue was resolved through a silent backend change at npm with no public advisory filed, no CVE assigned, and no changelog entry that would have notified affected maintainers. Detecting this kind of silent registry-side fix requires actively monitoring npm’s engineering blog and changelog for authentication-related changes — not something most maintainers have time to do systematically. Tracking these issues matters because the security property you gain from trusted publishing is only as strong as the Action and registry implementation you are relying on. Monitoring release feeds and commit histories for the tools in your publish pipeline is part of the security posture, not optional hygiene.
Target systems: PyPI Trusted Publishing (2023+), npm Provenance and OIDC publishing (2024+), GitHub Actions.
Threat Model
-
Attacker exfiltrates
NPM_TOKENorPYPI_TOKENfrom a compromised CI runner and publishes a backdoored package version. The token is present as an environment variable during the workflow run. A compromised runner — through a vulnerable self-hosted runner image, a malicious step injected via a compromised Action, or a GitHub Actions runner takeover via a malicious workflow step — reads the environment and exfiltrates the secret to an attacker-controlled endpoint. The attacker then uses the token outside of CI to publish a new version of the package with malicious code embedded. Because the package already has a trusted reputation, many downstream consumers update to the new version automatically. -
Malicious transitive build dependency reads
NPM_TOKENfrom the environment and exfiltrates it. Duringnpm installorpip install, lifecycle scripts or custom install hooks run arbitrary code in the same process environment that holds the publish token. A malicious transitive dependency — one that was itself compromised at any point in its own supply chain — can readprocess.env.NPM_TOKENoros.environ["PYPI_TOKEN"]and post it to an external URL. The build step and the install step share the same environment, so there is no isolation boundary between dependency resolution and the secrets used later in the pipeline. -
Patch-gap attacker monitors
pypa/gh-action-pypi-publishPRs and identifies a security fix before release. A security-aware attacker follows the commit history and open pull requests of the canonical PyPI publishing Action. When a PR is merged that closes a vulnerability — a misconfigured permission check, a missing event context validation — the attacker has a window between the merge and the next tagged release in which the fix code is public but the release has not been cut. During this window, the attacker can analyze the patch, understand the exploitable condition in prior versions, and target repositories whose workflow files pin to the unfixed@release/v1tag. Because the Action tag points to the latest commit in the release branch, repositories that pin by tag rather than by digest are automatically updated when a new release is cut — but not before. -
Misconfigured trusted publisher allows a fork PR workflow to obtain a publish token and push a malicious release. A repository configures PyPI trusted publishing and adds a publish workflow. The workflow trigger includes
on: pull_requestrather than being restricted toon: pushwith atagsfilter. A contributor who forks the repository opens a pull request that modifies the workflow to run the publish step. Depending on the exact configuration of the PyPI trusted publisher and the version of the publishing Action in use, the OIDC exchange may succeed, issuing a real upload token. The fork PR publishes a malicious version of the package before the maintainer reviews the pull request.
The blast radius from a compromised long-lived token is unbounded in time: the attacker can publish new versions at any point until someone notices and revokes the token. With trusted publishing, the worst case for a single workflow run compromise is one publish window (under fifteen minutes). The fork PR misconfiguration scenario closes entirely once the workflow trigger and environment protection are configured correctly. The patch-gap scenario is mitigated by pinning the Action to a digest rather than a tag and automating digest updates through Dependabot with review gates.
Configuration / Implementation
PyPI Trusted Publishing setup
In the PyPI project settings, navigate to Your projects > [project name] > Publishing. Under “Add a new publisher”, select GitHub Actions and fill in:
- PyPI project name: the exact package name as registered on PyPI
- Owner: the GitHub organization or user account that owns the repository (case-sensitive)
- Repository name: the repository name without the owner prefix
- Workflow filename: the exact filename of the publish workflow, e.g.
publish.yml - Environment name (optional but recommended): the name of the GitHub Actions environment that the publish job will use, e.g.
pypi
The corresponding GitHub Actions workflow must request the id-token: write permission at the job or workflow level, and must use an environment that matches what you configured in PyPI:
name: Publish to PyPI
on:
push:
tags:
- "v*.*.*"
jobs:
publish:
name: Upload release to PyPI
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/project/your-package-name/
permissions:
id-token: write # required for OIDC token exchange
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Build package
run: |
pip install build
python -m build
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@sha256:d2c8e1b08699c5f34671b2e0eb5af8a8e79c4e48
# pin to a specific digest rather than @release/v1
# update this digest via Dependabot (see Monitoring section)
The environment: pypi block gates the publish job behind the GitHub Actions environment named pypi. Configure that environment in your repository settings (Settings > Environments > pypi) with required reviewers and a deployment branch filter restricted to tag patterns (v*). This adds a human approval gate before the OIDC token exchange occurs, regardless of how the workflow was triggered.
npm OIDC trusted publishing
npm provenance and OIDC-based publishing requires the workflow to request id-token: write permission and to run npm publish with the --provenance flag. npm validates the OIDC token against the package’s configured trusted publisher settings in the npm registry.
name: Publish to npm
on:
push:
tags:
- "v*.*.*"
jobs:
publish:
name: Publish npm package
runs-on: ubuntu-latest
permissions:
id-token: write # required for OIDC token exchange and provenance
contents: read
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Publish with provenance
run: npm publish --provenance --access public
env:
NPM_CONFIG_PROVENANCE: "true"
# No NPM_TOKEN needed — OIDC handles authentication
No NPM_TOKEN secret is set. npm reads the OIDC token from the GitHub Actions environment automatically when id-token: write is granted. To verify provenance on a published package, run:
npm audit signatures your-package-name
This checks the Sigstore-backed provenance attestation attached to the published artifact and reports whether the signature chain is valid and traceable to the expected GitHub Actions workflow.
For npm organization packages that previously used granular access tokens, revoke those tokens from the npm web UI under Access Tokens after confirming trusted publishing is working. Leaving old tokens active after migration maintains the long-lived credential exposure you migrated away from.
Preventing fork PR abuse
The most common trusted publishing misconfiguration is a workflow that triggers on pull_request events. Fork PR workflows run with a limited permission set, but some configurations still permit the OIDC exchange. The correct trigger for any publish workflow is a push to a tag:
on:
push:
tags:
- "v*.*.*"
Add an explicit guard at the job level as defense in depth:
jobs:
publish:
if: >-
github.event_name == 'push' &&
startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
Never include pull_request or pull_request_target triggers in a workflow that contains a publish step. The pull_request_target trigger is particularly dangerous because it runs in the context of the base repository (with full secrets access) while potentially checking out code from a fork.
Pinning the publishing Action by digest
Pinning pypa/gh-action-pypi-publish by tag (@release/v1) means your workflow pulls whatever commit that tag currently points to. If the Action’s repository is compromised, or if a release cuts in the patch-gap window described in the threat model, the tag update silently changes your publish pipeline’s behavior. Pin to a specific commit digest instead:
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@sha256:d2c8e1b08699c5f34671b2e0eb5af8a8e79c4e48
Get the current digest by looking at the latest release on https://github.com/pypa/gh-action-pypi-publish/releases and clicking through to the commit. Automate digest updates with Dependabot by adding to .github/dependabot.yml:
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
publish-actions:
patterns:
- "pypa/gh-action-pypi-publish"
Dependabot will open a pull request when a new digest is available, giving you a review gate before the updated Action runs in production.
Monitoring the publishing Action for silent fixes
Subscribe to release notifications for pypa/gh-action-pypi-publish on GitHub (Watch > Custom > Releases). When a new release is published, read the release notes and compare the diff between the previous pinned digest and the new release commit. Look specifically for changes to:
- The OIDC token exchange logic (
src/or equivalent Action source files) - The
action.ymlinput validation - Permission checks on the triggering event context
To inspect commits since your pinned digest without pulling locally:
https://github.com/pypa/gh-action-pypi-publish/commits/release/v1
Compare timestamps against your pinned digest’s commit date. Any commit between your pin and the current release/v1 HEAD is running in the workflows of projects that pin by tag but not yours — and may contain a security fix you have not yet evaluated.
For npm, subscribe to the npm engineering blog at https://github.com/npm/statuspage and watch https://github.com/npm/cli/releases for changelog entries mentioning authentication, OIDC, provenance, or token scope. npm does not have a dedicated security advisory feed for registry-side auth changes; the engineering blog and CLI changelog are the most reliable signal available.
Signing published artifacts
Trusted publishing authenticates the workflow to the registry. Signing published artifacts provides a separate, verifiable claim about the artifact’s origin that consumers can check independently of the registry’s own verification.
For Python packages, PyPI’s Sigstore integration automatically generates a Sigstore bundle for sdist and wheel artifacts published through trusted publishing. No additional configuration is required; the bundle appears alongside the artifact in the PyPI download page. To sign explicitly using cosign for artifacts you distribute outside PyPI:
pip install build
python -m build
cosign sign-blob \
--bundle dist/your_package-1.0.0-py3-none-any.whl.bundle \
dist/your_package-1.0.0-py3-none-any.whl
Keyless signing through Sigstore uses the GitHub Actions OIDC identity as the signing identity, producing a publicly verifiable transparency log entry. The --bundle flag writes a JSON bundle containing the signature and inclusion proof, which consumers can verify with cosign verify-blob.
Auditing existing token usage
Before migrating, inventory all repositories that still use long-lived publish tokens. Using the GitHub CLI:
# List all secrets in a repository that match publish token patterns
gh secret list --repo your-org/your-repo
# List secrets across all repos in an org (requires org:read scope)
gh api /orgs/your-org/actions/secrets --paginate \
--jq '.secrets[] | select(.name | test("NPM_TOKEN|PYPI_TOKEN|TWINE_PASSWORD")) | .name'
Migration checklist for converting an existing project:
- Configure the trusted publisher in the PyPI or npm registry project settings
- Add the
id-token: writepermission to the publish job - Update the workflow trigger to
on: push: tags:only - Create a GitHub Actions environment (
pypiornpm) with required reviewers and deployment branch filter - Remove
NPM_TOKEN/PYPI_TOKENfrom the workflowenv:block - Run the publish workflow against a test release (e.g., a
v0.0.1-testtag on a non-production branch) and verify the OIDC exchange succeeds - After a successful production publish via trusted publishing, revoke the old token in the registry UI and delete the GitHub Actions secret
Do not delete the GitHub Actions secret before confirming the trusted publishing workflow succeeds end-to-end. A failed migration that removes the secret prematurely will break production publishing with no quick fallback.
Expected Behaviour
| Signal | Long-lived token | Trusted publishing |
|---|---|---|
| Malicious build dependency reads environment | Exfiltrates NPM_TOKEN/PYPI_TOKEN; attacker can publish indefinitely |
No persistent secret in environment; OIDC token scoped to current workflow run only |
| Fork PR attempts to trigger publish workflow | Workflow with NPM_TOKEN secret runs (secrets not available to fork PRs in default config, but pull_request_target bypasses this) |
Environment protection rule and tag-only trigger prevent token issuance; PyPI/npm reject requests that do not match registered workflow context |
| Token scope | Single token grants publish access to all packages the account owns, often across multiple registries | OIDC token scoped to one specific package and one specific workflow; registry issues a per-run upload credential |
| Publish audit trail | Registry records publish actor as the human account that generated the token | Registry records publish actor as the specific workflow run, repository, and commit SHA; npm provenance links artifact to a verifiable Sigstore transparency log entry |
Trade-offs
| Aspect | Benefit | Cost | Mitigation |
|---|---|---|---|
| OIDC token lifetime (~15 min) | Eliminates persistent credential exposure; compromised token is useless after publish window | Build step must complete and publish must run within the OIDC token validity window; large packages or slow networks can cause timeout failures | Break build and publish into separate jobs; ensure the publish job starts immediately after build artifacts are uploaded |
| Registry API availability dependency | No stored secrets means no credential to rotate or leak | Publish workflow fails if PyPI or npm OIDC endpoint is unavailable, even if your code and artifacts are fine | Implement retry logic in the publish step; monitor registry status pages; maintain a rollback plan that does not require re-publishing if the registry is temporarily unavailable |
| Action version pinning operational burden | Digest pinning eliminates silent Action compromise via tag mutation | Each security update to pypa/gh-action-pypi-publish requires a Dependabot PR review and merge before the fix is active in your pipeline |
Automate Dependabot for GitHub Actions with weekly schedule; configure auto-merge for patch-level Action updates after CI passes |
| No offline or air-gapped publishing | Trusted publishing requires a live OIDC exchange with the registry at publish time | Organizations with air-gapped CI environments or strict outbound network controls cannot use registry OIDC endpoints | Use trusted publishing for public packages; maintain a separate process with scoped short-lived tokens (rotated frequently) for internal registries; consider Artifactory or Nexus for air-gapped environments |
Failure Modes
| Failure | Symptom | Detection | Recovery |
|---|---|---|---|
| OIDC token exchange fails due to wrong workflow filename | PyPI or npm returns 403 Forbidden or invalid publisher during publish step; workflow fails at the upload step with an authentication error |
Compare the workflow-filename field in the PyPI trusted publisher configuration exactly against the workflow file’s filename on disk (including .yml vs .yaml extension); check runner logs for the specific OIDC error message returned by the registry |
Update the trusted publisher configuration in the registry settings to match the exact filename; or rename the workflow file and update the trusted publisher — changes take effect immediately with no deploy required |
| Environment protection rule blocks automated publish | Publish job hangs waiting for required reviewer approval; CI notification shows “Waiting for deployment approval” | GitHub Actions UI shows the job in a pending approval state on the environment page; check Settings > Environments > [env name] > Required reviewers | For fully automated releases (e.g., from a release bot), either remove required reviewers from the environment or configure the approval to be granted automatically by a bot identity; retain required reviewers for human-initiated releases |
| Action digest pinned to vulnerable version | Old version of pypa/gh-action-pypi-publish contains a known vulnerability (e.g., fork PR token exchange issue); projects that pin by digest do not receive the fix automatically |
Dependabot does not open a PR if the vulnerable version was pinned as a digest before the fix was released — requires manual monitoring of the Action’s release feed and commit history | Update the pinned digest in the workflow file to the digest of the fixed release; verify by comparing the digest against the tag’s associated commit on GitHub; merge and confirm the next publish run succeeds |
| PyPI trusted publisher configuration deleted | Publish workflow fails with 403 or trusted publisher not found error immediately after the OIDC exchange; the registry does not recognize the workflow as authorized |
Compare the error message from the publish step against the trusted publisher list in PyPI project settings; the publisher list may be empty or missing the expected entry | Re-add the trusted publisher configuration in PyPI settings with the correct owner, repository, workflow filename, and environment name; no secret rotation is required; verify by running a test publish against a pre-release version |