If you publish NuGet packages from GitHub Actions today, you are almost certainly managing an API key somewhere: generating it on nuget.org, storing it as a repository secret, and rotating it every so often when you remember to. NuGet.org recently added Trusted Publishing, which removes that entire chain of custody. Andrew Lock covered this on his blog when the feature landed, and it is worth walking through because it changes a real operational headache into something you configure once and mostly forget about.
How Most Teams Push Packages Today
Most .NET teams building NuGet packages fall into one of three patterns: building locally and dragging the .nupkg onto the nuget.org upload page, building in CI and downloading the artifact for a manual upload, or building in CI and pushing directly to nuget.org from the pipeline. Each step up this list improves consistency and cuts down manual work, but it also adds a dependency that was not there when you were just uploading from your laptop: an API key that CI needs to authenticate with.
Getting that key into CI safely means going through a checklist every time: generate the key on nuget.org, store it as a GitHub Actions secret, reference it correctly in the workflow, rotate it periodically, and if you work in a team, make sure everyone with publish rights can do the same without emailing keys around. None of these steps is hard on its own, but long-lived credentials are exactly the kind of thing that goes stale, gets over-shared, or sits unrotated for years because nobody wants to touch a working pipeline. This is the gap Trusted Publishing closes.
What Trusted Publishing Actually Does
Trusted Publishing is not a NuGet invention. PyPI has had it for Python packages for a while, RubyGems.org supports it for Ruby, and npm has an equivalent for JavaScript. NuGet.org added support for it recently, which brings .NET in line with how the other major package ecosystems already handle CI authentication.
The mechanism relies on OpenID Connect, the same standard a lot of enterprise SSO already runs on. Instead of a long-lived API key sitting in a secrets store, your CI provider issues a short-lived, cryptographically signed token for each job. The package registry checks that token against a trust policy you configured ahead of time, and if it matches, issues a temporary API key scoped to that one publish operation. There is nothing sitting around waiting to be leaked, because there is nothing long-lived to leak.
For GitHub Actions publishing to nuget.org specifically, the sequence looks like this:
- You configure a trust policy on nuget.org ahead of time, tied to a specific GitHub repository and workflow file.
- In the CI job, GitHub (acting as the identity provider) issues an OpenID Connect token, a signed JWT carrying details about the repository and the workflow that is running.
- Your workflow sends that token to nuget.org, which verifies its contents against the trust policy. If everything matches, nuget.org issues a short-lived API token.
- The workflow uses that temporary token to push the .nupkg files, and the token expires shortly after.

The reason this works smoothly is that the trust relationship is set up in advance and GitHub already authenticates the job as part of running it. You are not bootstrapping trust at publish time, you are just proving that this specific job, from this specific repository and workflow file, is the one you already said was allowed to publish.
Setting Up Trusted Publishing: The Starting Workflow
Andrew Lock walks through this using his own sleep-pc tool as an example, starting from a plain build-and-pack workflow with no publishing step at all. That baseline is stored in .github/workflows/BuildAndPack.yml and looks like this:
name: BuildAndPack
on:
push:
branches: [main]
tags: ['*']
pull_request:
jobs:
build-and-publish:
permissions:
contents: read
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: dotnet pack
run: |
dotnet pack
dotnet pack -r win-x64
This workflow does exactly three things: check out the repository, install the .NET 10 SDK, and run dotnet pack twice, once for a generic package and once for a win-x64 self-contained variant. Note the permissions block scoping the job token down to contents: read. That is not required for this baseline to work, but it is worth adding as a habit, since the default GitHub Actions token otherwise carries broader write access than a pack-only job needs. It also makes the diff in the next step easier to follow, because you can see exactly which permission gets added.
Adding Trusted Publishing to the Workflow
Turning this into a publishing workflow with Trusted Publishing takes three changes:
- Add the id-token: write permission so the job can request an OIDC token from GitHub.
- Use the NuGet/login@v1 action to exchange that OIDC token for a short-lived NuGet API key.
- Use the generated API key in a dotnet nuget push step to actually publish the packages.
Here is the updated workflow with those changes in place:
name: BuildAndPack
on:
push:
branches: [main]
tags: ['*']
pull_request:
jobs:
build-and-publish:
permissions:
contents: read
id-token: write # enable GitHub OIDC token issuance for this job
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: dotnet pack
run: |
dotnet pack
dotnet pack -r win-x64
# Use the ambient GitHub token to login to NuGet and retrieve an API key
- name: NuGet login (OIDC -> temp API key)
uses: NuGet/login@v1
id: login
with:
# Secret is your NuGet username, e.g. andrewlock
user: ${{ secrets.NUGET_USER }}
- name: push to NuGet
# Only push to NuGet if we're building a tag (optional)
if: startsWith(github.ref, 'refs/tags/')
shell: pwsh
# Loop through all the packages in the output folder and push them to
# nuget.org, using the NUGET_API_KEY generated by the previous login step
run: |
Get-ChildItem artifacts/package/release -Filter *.nupkg | ForEach-Object {
dotnet nuget push $_.FullName `
--api-key "${{ steps.login.outputs.NUGET_API_KEY }}" `
--source https://api.nuget.org/v3/index.json
}
The only secret this workflow needs is NUGET_USER, set to your nuget.org username, not your email address. That is a small detail but worth calling out, since it is easy to assume the login action wants the account email the way the nuget.org web login does. Storing the username as a secret feels slightly excessive since it is not sensitive information, but there is no real downside to it either.

The push step is also gated behind an if condition so it only runs on tag builds, which is a sensible default. You probably do not want every pull request build attempting a publish, even if the credential exchange would fail safely due to the trust policy not matching.
Running this workflow before the trust policy exists on nuget.org fails at the login step, with an error along these lines:
Requesting GitHub OIDC token from: https://run-actions-1-azure-eastus.actions.githubusercontent.com/100//idtoken/...
Error: Token exchange failed (401): No matching trust policy owned by user '***' was found.
That failure is expected and tells you exactly what is missing: nuget.org does not yet know this repository and workflow are allowed to publish. The trust policy has to exist on the nuget.org side before the token exchange can succeed, which is the next step.
Configuring the Trust Policy on NuGet.org
Setting up the trust policy is a short form, done once per package. Sign in at nuget.org and open the Trusted Publishing page from the account menu.

Click Create, then fill in four fields:
- A name for the policy. Naming it after the package or GitHub repository keeps things unambiguous if you manage several packages.
- The package owner, which can be an individual account or an organisation.
- The GitHub repository details: owner and repository name.
- The workflow file path that is allowed to publish, matching the .yml file in .github/workflows.

That last field matters more than it looks. The trust policy is scoped to a specific workflow file path, not just the repository, so if you rename the workflow file later or move it, you need to update the policy to match, or publishing will start failing with the same 401 you saw earlier.
Once created, the policy sits in a partially active state until it has been used successfully at least once:

Re-running the workflow now, the login step succeeds:
Requesting GitHub OIDC token from: https://run-actions-1-azure-eastus.actions.githubusercontent.com/73//idtoken/...
Successfully exchanged OIDC token for NuGet API key.
and the trust policy flips to fully active on the nuget.org side:

From here, the workflow publishes exactly like it would with a stored API key, except there is no key sitting in your secrets store for anyone to accidentally leak, log, or forget to rotate.
Practical Considerations Before You Switch
This is a genuine improvement for anyone publishing packages regularly, but a few things are worth knowing before you migrate every repository over. The trust policy is tied to one workflow file path, so if you use reusable workflows or call a shared publish workflow from multiple repositories, check how the policy matching handles that setup before assuming it just works. GitHub’s own community discussions on Trusted Publishing flag this as an area where the matching rules can be stricter than people expect, especially with called workflows in a different repository.
Windows-hosted runners, as used in this example, are billed at a higher per-minute rate than Linux runners on GitHub Actions. If your package does not have Windows-specific build requirements, packing on ubuntu-latest instead is worth considering purely for cost, independent of the Trusted Publishing change.
There is also no rollback story if a trust policy is misconfigured while a workflow file is mid-refactor: publishing will simply fail with the same 401 error until you fix the path in the policy or the workflow. That is a safer failure mode than a stale API key silently continuing to work after someone has left the team, but it does mean you should test policy changes on a non-release branch before merging a workflow rename to main.
If you are on Azure DevOps rather than GitHub, there is no direct nuget.org Trusted Publishing equivalent yet, but Azure DevOps service connections with workload identity federation solve the same underlying problem for private Azure Artifacts feeds, using OIDC in a comparable way. Worth checking if your organisation already standardises on Azure DevOps pipelines rather than GitHub Actions.
Wrapping Up
Trusted Publishing does not add new capabilities to what you could already do with an API key stored as a GitHub secret. What it removes is the operational overhead of managing that key safely over the package’s lifetime: generating it, storing it, rotating it, and making sure nobody with publish access needs to share it informally. For any package published regularly from CI, that is a solid trade of a few minutes of one-time setup for a maintenance chore removed permanently.
Leave a Reply