Migrating 35 Repositories to NuGet Trusted Publishing
Our release pipelines no longer contain a single NuGet API key. Across 35 repositories, NUGET_API_KEY and NUGET_TOKEN are gone, replaced by NuGet Trusted Publishing. I finally found the time over a weekend for a step that was long overdue and never got the priority it deserved. As long as releases kept going out, there was always something more urgent. The migration itself is small: one policy on nuget.org and a few lines of YAML per repository. The one real design decision concerns everyone who publishes through shared pipelines, and that is where I lost the most time, so it gets its own section below.
Why a NuGet API key is a problem
A classic NuGet API key is a bearer secret. Whoever holds the string can push packages within the scope of the key, from anywhere and at any time, until it expires or someone revokes it. In a CI/CD setup that has consequences which are easy to ignore as long as nothing happens.
The key has to be stored where the pipeline can read it, which means repository secrets, organization secrets, or environment secrets. Every one of those is a copy, and every copy is a place where the key can leak: through a compromised workflow, a malicious third-party action, a debug step that prints the environment, or a former colleague who still has the original on a laptop. On top of that, keys expire. Sooner or later a release fails because the key is gone, and you find out on release day rather than during a calm maintenance window.
Rotation is the part that really hurts. Doing it properly means creating a new key, updating every secret that holds it, verifying a release, and revoking the old key. For one repository that is a chore. For 35 it is a project that nobody wants to own, which means it does not happen on schedule. And it is worth remembering what is at stake: a package push is a supply chain event. Whatever lands on nuget.org ends up in the builds of everyone who consumes the package, so the credential behind it deserves more care than a string in a settings page. (The consuming side of the same problem is covered in NuGet Packages: Suppliers You Forgot to Audit.)
Trusted Publishing attacks the root cause: there is no long-lived credential left to store, leak, or rotate.
How Trusted Publishing works
The official documentation describes the flow, and it builds on OIDC (OpenID Connect), the same mechanism that lets GitHub Actions authenticate against Azure or AWS without stored credentials. The GitHub Actions job first requests an OIDC token from GitHub. This token is signed by GitHub and carries claims about the repository, the workflow, and the run. The NuGet/login action then sends the token to nuget.org, which validates the signature and checks the claims against a trusted publishing policy that you created beforehand. If a policy matches, nuget.org returns a temporary API key, and that key goes into dotnet nuget push.
Two properties limit the damage if something goes wrong. The temporary key is valid for one hour, so you should request it right before the push and not at the start of a long pipeline. And each OIDC token can be exchanged only once, so even a token that leaks from a log is worthless after the pipeline has used it.
What a policy contains
The policy is where the trust is defined. For GitHub it binds to the repository owner, the repository, the workflow file name (not the path), and optionally an environment. Matching is case-insensitive. A policy is owned by a user or an organization, and its scope controls what it may publish: either all packages of the owner or only packages matching a glob pattern, and either new packages as well or only new versions of existing ones.
A new policy can start as temporarily active for 7 days, which typically happens with private repositories. A successful publish within that window makes the policy permanent, because NuGet then records the numeric GitHub IDs of the owner and repository. This protects against someone deleting a repository and recreating it under the same name to take over the policy. If nothing is published within 7 days, the policy goes inactive and you can restart the window.
Migrating one repository
Create the policy on nuget.org
Open your user menu on nuget.org, choose Trusted Publishing, and add a policy. For a repository contoso/contoso-sdk with the workflow .github/workflows/release.yml you enter contoso as repository owner, contoso-sdk as repository, and release.yml as workflow file. The environment field is only needed if the publishing job declares one, for example environment: release. Entering the full path .github/workflows/release.yml instead of the bare file name is an easy mistake to make here.
Update the workflow
name: Release
on:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: ubuntu-latest
environment: release # optional, must match the policy if set there
permissions:
id-token: write # lets the job request a GitHub OIDC token
contents: read # job-level permissions replace the defaults
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
with:
dotnet-version: 10.0.x
- run: dotnet pack --configuration Release --output ./artifacts
# Request the key right before the push, it is valid for one hour
- name: NuGet login
uses: NuGet/login@v1
id: login
with:
user: ${{ secrets.NUGET_USER }} # nuget.org profile name, not your email
- run: >
dotnet nuget push ./artifacts/*.nupkg
--api-key ${{ steps.login.outputs.NUGET_API_KEY }}
--source https://api.nuget.org/v3/index.json
--skip-duplicate
A few details in this workflow matter. The id-token: write permission is what allows the job to request the OIDC token, and without it the login step fails. Because permissions defined on the job replace the defaults, contents: read has to be added back for the checkout. The user input is the profile name of the nuget.org account that owns the policy, not the email address. It is not really a secret, but a profile name is easy to confuse with the account email, and keeping it in a secret saves you from hardcoding it in every repository. Finally, the login step sits after build and pack on purpose. The key lives for one hour, so there is no reason to request it earlier.
Clean up
After the first successful release, delete the old secret and revoke the key on nuget.org:
gh secret delete NUGET_API_KEY --repo owner/repo
This is the step that is easy to skip because everything already works. A repository with Trusted Publishing and a still valid API key has gained nothing, because the old way in stays open.
Reusable workflows do not work
This is where I lost most of my time. Many of our repositories do not contain their own publish logic. They call a shared reusable workflow from our central pipelines repository, so the publish steps exist once and not 35 times. The obvious migration was to add the NuGet login to that shared workflow, grant id-token: write in the caller, and create one policy per repository.
It fails with a login error, and the reason is in the token. nuget.org validates the repository ID of the caller, because the policy is bound to the repository that runs the pipeline. At the same time it checks the job_workflow_ref claim, which names the workflow file that contains the job. With a reusable workflow, the job lives in the called workflow, so job_workflow_ref points to the file in the shared repository while the repository ID belongs to the caller. A policy that matches the one does not match the other. I observed this in a failing run, and the documentation does not describe the combination, so treat it as experience and verify it for your setup.
Use a composite action instead
A composite action behaves differently, because its steps run inside the job of the caller. That makes job_workflow_ref the caller’s own workflow file, both claims point to the same repository, and a single policy matches. We moved the publish logic into a composite action in our shared pipelines repository that downloads the package artifacts of the build run, calls NuGet/login, and pushes with the temporary key. The caller keeps the job and its permissions:
jobs:
publish:
runs-on: ubuntu-latest
environment: release
permissions:
actions: read # download artifacts of another workflow run
id-token: write # request the OIDC token
steps:
- uses: dailydevops/pipelines/.github/actions/nuget-publish@2.7.0
with:
nuget-user: ${{ secrets.NUGET_USER }}
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
The policy on nuget.org then names the workflow file of the calling repository, which is exactly what you would have configured without any shared pipeline. The price is that the job, its environment, and its permissions are now defined in each caller. For us that is a fair trade, because those lines belong in the repository that publishes anyway.
A transition path helps as well. In our shared reusable workflows we kept the legacy NUGET_TOKEN as an optional fallback and made the new NUGET_USER secret optional too. If NUGET_USER is set, the workflow uses the login action, otherwise it falls back to the old key. That lets consumers switch repository by repository instead of all at once.
Doing it for many repositories
The per-repository work takes minutes, so the real effort is coordination. The most useful preparation is to standardize workflow file names first. The policy contains the file name, and if every repository publishes from release.yml, creating policies becomes copy and paste instead of reading through 35 workflow folders.
The second decision is who owns the policies. A policy applies to packages owned by the policy owner, so for packages owned by an organization the policy must be owned by that organization. The documentation points out that such a policy becomes inactive if the user who created it is removed from the organization, so know who created what before somebody leaves. I also did not find a documented API for creating policies, which means the policies are created by hand in the nuget.org UI.
For the rollout itself I would verify one release per repository before deleting the old key, because the first successful run is the only real test of a policy. It also pays off to track five states per repository: policy created, workflow changed, release verified, secret deleted, key revoked. It is boring, but with 35 repositories it is exactly where things get forgotten.
Pitfalls
Most failures come from a handful of causes. The policy matches by file name, so renaming release.yml makes the next release fail with a “no matching policy” error. If the policy names an environment, the job must declare the same one, and if the policy leaves it empty, the environment is not part of the check. A missing id-token: write prevents the job from requesting the OIDC token at all, which is easy to overlook in a caller that previously did not need any special permissions.
Two cases are less obvious. If updates to existing packages work but a brand-new package fails, check the policy scope first, because scopes decide whether new packages may be published at all. And if a freshly created policy stops working, check the 7-day activation window and the policy owner, as described above.
Why you should do this soon
The old way still works, and that is exactly why it stayed on my list for so long. But every new repository adds another copy of the key, every colleague who leaves raises the question of where the key ended up, and a leaked key or a failed rotation forces the migration under pressure instead of on a quiet weekend. The effort itself stays the same, so the only thing waiting buys you is risk.
It is also the direction of the ecosystem. The NuGet documentation points to the OpenSSF initiative on trusted publishers, which describes keyless publishing for package repositories in general, so what you learn here carries over. If you maintain libraries, the same supply chain thinking shows up in regulation as well, as I wrote in Cyber Resilience Act for .NET Library Authors.
Conclusion
Pick your most active repository and migrate it this week, including the cleanup of the old key. Standardize workflow file names before you migrate the rest, and check who owns your policies, especially in an organization. If you publish through shared pipelines, use a composite action and not a reusable workflow. And revoke the old API keys as part of every migration, not afterwards.

Comments