
Publishing NuGet packages has traditionally required one uncomfortable compromise: a long-lived API key had to exist somewhere in the delivery pipeline. Even when that secret was stored in a secure CI system, the model still relied on a credential that could be leaked, copied, mis-scoped or forgotten. Once exposed, that key could often be reused until someone noticed the incident and rotated it.
NuGet Trusted Publishing changes that model in a meaningful way. Instead of storing a permanent publishing credential, the pipeline proves its identity to nuget.org through OpenID Connect (OIDC). NuGet validates that identity against a trusted publishing policy and returns a short-lived API key that exists only for the current release run. The practical result is a release pipeline that is easier to automate and significantly safer to operate.
For .NET teams that publish libraries regularly, this is more than a small DevOps improvement. It reduces secret management overhead, narrows the blast radius of a compromised workflow and aligns package publishing with the broader industry move toward keyless, identity-based delivery. In an ecosystem where supply-chain trust matters as much as functionality, that shift is important.
Why Trusted Publishing Matters
The traditional NuGet publishing model usually looks simple: create an API key on nuget.org, store it in GitHub Secrets and pass it to dotnet nuget push. The problem is not that this workflow is hard to implement. The problem is that it creates a durable secret that tends to outlive the original context in which it was created.
That design introduces several avoidable risks:
- A leaked API key can be reused outside the intended workflow.
- Secret rotation is manual and often deferred.
- Access scope is rarely reviewed after the initial setup.
- Forked or copied pipeline definitions can accidentally spread publishing logic farther than intended.
- Auditing who or what actually performed a publish becomes more difficult.
Trusted Publishing replaces that long-lived secret with a temporary credential exchange. A GitHub Actions workflow requests an OIDC token from GitHub. That token is cryptographically signed and contains claims about the repository and workflow that requested it. The workflow forwards the token to nuget.org. NuGet verifies the token, checks it against the configured policy and issues a short-lived API key only if the claims match the expected repository, workflow file and optionally the deployment environment.
This model materially improves security because the publishing credential is no longer a reusable secret sitting in repository settings. It is created just in time, bound to a specific workflow identity and valid only for a limited period.
How NuGet Trusted Publishing Works
At a high level, the process is straightforward:
- A GitHub Actions workflow starts.
- The workflow requests an OIDC token from GitHub.
- The workflow sends that token to nuget.org.
- NuGet validates the token and the configured trusted publishing policy.
- NuGet returns a temporary API key.
- The workflow uses that temporary key to push the package.
There are two details that are especially important from an operational perspective.
First, the temporary API key is short-lived. According to the current NuGet documentation, it is valid for one hour. That means the login step should happen close to the actual push step. Fetching the key too early in a long-running workflow can result in an expired credential before publishing starts.
Second, each OIDC token can only be exchanged once for a single temporary API key. That one-time exchange property prevents a token from being reused across multiple publish attempts.
The nuget.org Configuration
Trusted Publishing is configured on nuget.org, not inside GitHub alone. In the nuget.org UI, the Trusted Publishing section allows creation of a policy for a user or organization that owns the package.
For a GitHub repository such as https://github.com/BenjaminAbt/Unio, the key fields are:
- Repository Owner:
BenjaminAbt - Repository:
Unio - Workflow File:
release-publish.yml - Environment: leave empty unless the publishing workflow is explicitly bound to a GitHub Environment.
One implementation detail is easy to miss: the workflow field expects the file name only, not the full path under .github/workflows/. In other words, the correct value is release-publish.yml, not .github/workflows/release-publish.yml.
If GitHub Environments are used for release hardening, the environment name can also be added to the policy. That creates a stronger binding between nuget.org and the exact deployment boundary used in GitHub.
NuGet also distinguishes policy ownership. A policy can belong either to an individual account or to an organization on nuget.org. That decision matters because the policy governs who can publish packages owned by that account or organization. If the policy owner relationship changes later, the policy can become inactive.
There is another operational nuance worth understanding. For some cases, especially private repositories, a new policy may initially appear as temporarily active for seven days. The first successful publish allows NuGet to capture immutable GitHub owner and repository identifiers, after which the policy becomes fully active. This protects against repository resurrection scenarios where a deleted repository could later be recreated under the same name.
Example Implementation with GitHub Actions
The most robust setup is not a single monolithic workflow. The Unio repository demonstrates the staged release structure well and a recommended variant of that pattern looks like this:
- Pull requests validate build, test and package readiness.
- A push to
mainproduces a draft release and attaches the generated NuGet artifacts. - Publishing that GitHub Release triggers the actual upload to nuget.org through Trusted Publishing.
This design is worth calling out because it aligns the security boundary with the real release boundary. Build and packaging can run often. NuGet publication should run only when a release is intentionally published.
Step 1: Reusable build, test and pack workflow
The foundation is a reusable workflow that centralizes compilation, test execution, version calculation and optional package creation.
1name: Build and Test (Reusable)
2
3on:
4 workflow_call:
5 inputs:
6 configuration:
7 type: string
8 default: Release
9 upload-test-results:
10 type: boolean
11 default: false
12 create-pack:
13 type: boolean
14 default: false
15 outputs:
16 version:
17 value: ${{ jobs.build.outputs.version }}
18
19jobs:
20 build:
21 runs-on: ubuntu-latest
22 outputs:
23 version: ${{ steps.nbgv.outputs.SemVer2 }}
24
25 steps:
26 - name: Checkout code
27 uses: actions/checkout@v4
28 with:
29 fetch-depth: 0
30
31 - name: Setup .NET (stable)
32 uses: actions/setup-dotnet@v4
33 with:
34 dotnet-version: |
35 8.0.x
36 9.0.x
37 10.0.x
38
39 - name: Setup .NET (preview)
40 uses: actions/setup-dotnet@v4
41 with:
42 dotnet-version: 11.0.x
43 dotnet-quality: preview
44
45 - name: Calculate version with NBGV
46 id: nbgv
47 uses: dotnet/nbgv@master
48 with:
49 setAllVars: true
50
51 - name: Build and test
52 run: >-
53 dotnet test
54 --configuration ${{ inputs.configuration }}
55 --verbosity normal
56 --logger "trx;LogFileName=test-results.trx"
57 /p:Version=${{ steps.nbgv.outputs.SemVer2 }}
58
59 - name: Pack NuGet packages
60 if: inputs.create-pack
61 run: >-
62 dotnet pack
63 --configuration ${{ inputs.configuration }}
64 --no-build
65 --include-symbols
66 -p:SymbolPackageFormat=snupkg
67 --output ./artifacts
68 /p:PackageVersion=${{ steps.nbgv.outputs.SemVer2 }}
69
70 - name: Upload NuGet packages
71 if: inputs.create-pack
72 uses: actions/upload-artifact@v4
73 with:
74 name: nuget-packages
75 path: |
76 ./artifacts/*.nupkg
77 ./artifacts/*.snupkg
The important characteristic here is reuse. The repository does not duplicate build logic between pull requests, main branch builds and release publication. That keeps versioning and package creation consistent across all stages.
Step 2: Pull request validation
The PR workflow stays narrow. Its job is to prove that the codebase is releasable without actually creating or publishing a release.
1name: PR Validation
2
3on:
4 pull_request:
5 branches:
6 - main
7
8permissions:
9 contents: read
10 pull-requests: read
11
12jobs:
13 validate:
14 uses: ./.github/workflows/build-and-test.yml
15 with:
16 upload-test-results: true
17 create-pack: true
This is the first half of the recommended workflow design. A pull request should fail before merge if the code does not compile, tests do not pass or packaging is broken. That catches release failures while the change is still under review.
The live Unio repository currently centralizes package creation in the reusable release path and uses PR validation primarily for build and test verification. Enabling create-pack: true in PR validation is the recommended extension when packageability should be enforced before merge as well.
Step 3: Main branch build creates the draft release
After code is merged, the main workflow builds the project again in a controlled branch context, creates the NuGet packages and prepares a draft GitHub Release. The release is not yet public and nothing is pushed to nuget.org at this point.
1name: Main Build
2
3on:
4 push:
5 branches:
6 - main
7
8jobs:
9 build-and-test:
10 uses: ./.github/workflows/build-and-test.yml
11 with:
12 create-pack: true
13
14 create-draft-release:
15 needs: build-and-test
16 runs-on: ubuntu-latest
17 permissions:
18 contents: write
19 pull-requests: read
20
21 steps:
22 - name: Checkout code
23 uses: actions/checkout@v4
24
25 - name: Download NuGet packages
26 uses: actions/download-artifact@v4
27 with:
28 name: nuget-packages
29 path: ./artifacts
30
31 - name: Create draft release
32 uses: release-drafter/release-drafter@v6
33 env:
34 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
35 with:
36 config-name: release-drafter.yml
37 version: v${{ needs.build-and-test.outputs.version }}
38 tag: v${{ needs.build-and-test.outputs.version }}
39 name: Version ${{ needs.build-and-test.outputs.version }}
40 publish: false
41
42 - name: Upload packages to draft release
43 env:
44 GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
45 run: |
46 TAG="v${{ needs.build-and-test.outputs.version }}"
47 for file in ./artifacts/*.nupkg ./artifacts/*.snupkg; do
48 [ -e "$file" ] || continue
49 gh release upload "$TAG" "$file" --clobber
50 done
This middle stage is where the pattern becomes especially strong. The artifacts that will later be published are attached to the draft release first. That creates a reviewable checkpoint: version, release notes and binary assets can all be inspected before the irreversible step of public package publication happens.
Step 4: Publishing the GitHub Release triggers NuGet publication
The final workflow is the only one that actually needs Trusted Publishing. It runs on release.published, downloads the already prepared release assets, verifies them, exchanges the OIDC token for a temporary NuGet API key and pushes the packages.
1name: Publish Release
2
3on:
4 release:
5 types: [published]
6
7jobs:
8 publish-nuget:
9 runs-on: ubuntu-latest
10 permissions:
11 id-token: write
12 contents: read
13
14 steps:
15 - name: Download release assets
16 env:
17 GH_TOKEN: ${{ github.token }}
18 run: |
19 gh release download "${{ github.event.release.tag_name }}" \
20 --pattern "*.nupkg" \
21 --pattern "*.snupkg" \
22 --dir ./artifacts \
23 --repo "${{ github.repository }}"
24
25 - name: Setup .NET
26 uses: actions/setup-dotnet@v4
27 with:
28 dotnet-version: |
29 8.0.x
30 9.0.x
31 10.0.x
32 11.0.x
33
34 - name: Verify packages
35 run: |
36 PACKAGE_COUNT=$(ls ./artifacts/*.nupkg | wc -l)
37 SYMBOL_COUNT=$(ls ./artifacts/*.snupkg | wc -l)
38
39 if [ "$PACKAGE_COUNT" -eq 0 ] || [ "$SYMBOL_COUNT" -eq 0 ]; then
40 echo "Missing package or symbol package artifacts"
41 exit 1
42 fi
43
44 if [ "$PACKAGE_COUNT" -ne "$SYMBOL_COUNT" ]; then
45 echo "Package and symbol package counts do not match"
46 exit 1
47 fi
48
49 - name: NuGet login (OIDC to temp API key)
50 id: login
51 uses: NuGet/login@v1
52 with:
53 user: ${{ secrets.NUGET_USER }}
54
55 - name: Publish to NuGet.org
56 run: |
57 for package in ./artifacts/*.nupkg; do
58 dotnet nuget push "$package" \
59 --api-key "${{ steps.login.outputs.NUGET_API_KEY }}" \
60 --source https://api.nuget.org/v3/index.json \
61 --skip-duplicate
62 done
63
64 for symbol in ./artifacts/*.snupkg; do
65 dotnet nuget push "$symbol" \
66 --api-key "${{ steps.login.outputs.NUGET_API_KEY }}" \
67 --source https://api.nuget.org/v3/index.json \
68 --skip-duplicate
69 done
This is the critical security boundary. The workflow file that must be configured in nuget.org Trusted Publishing is the one above, because this is the workflow that requests the OIDC token and actually calls NuGet/login.
Several parts of this staged design deserve explicit attention.
permissions.id-token: write
This permission appears only in the publishing workflow, not in the general validation workflows. That is exactly the right shape. The ability to request an OIDC token for package publication should exist only where package publication itself is allowed.
Release assets as the publication source
The publish workflow does not rebuild the solution. It downloads the .nupkg and .snupkg files from the GitHub Release and publishes those exact assets. That keeps the public package aligned with the reviewed draft release artifacts and avoids subtle differences between build time and publish time.
NuGet/login@v1
This action handles the OIDC exchange and exposes the temporary NuGet API key as an output. The user value should be the nuget.org profile name, not an email address. In a team setting, this is usually best represented by a stable package owner identity.
Artifact verification before publish
The release workflow verifies that package and symbol package counts are present and aligned before any push begins. That check is small, but it prevents partially assembled releases from being published to nuget.org.
Recommended Release Design
Trusted Publishing works best when it is treated as one stage in a broader release system rather than as an isolated login mechanism.
The recommended workflow design, reflected by the Unio setup, looks like this:
- A reusable workflow owns build, test, versioning and optional packaging.
- Pull requests use that workflow to validate releasability before merge.
mainuses the same workflow to produce versioned artifacts and a draft release.- Only a published GitHub Release triggers nuget.org publication.
This separation is important for both correctness and security.
PR validation answers whether the change is safe to merge. The main branch workflow answers whether the repository state is ready to become a release candidate. The release publication workflow answers whether a human-reviewed draft should become a public package. Those are different questions and each deserves its own trigger and permission scope.
This sequencing also fits NuGet Trusted Publishing particularly well. Since the temporary API key is time-bound, it should not be fetched at the beginning of a long-running workflow. In the staged design, the OIDC exchange happens only after the release artifacts already exist and immediately before the actual dotnet nuget push commands.
Best Practices for .NET and NuGet Trusted Publishing
1. Restrict publishing to a dedicated workflow
Publishing should happen from one clearly named workflow file only. This keeps the trusted publishing policy narrow and makes audits easier. In the staged design above, that file is the release publication workflow, not the PR or main build workflow.
2. Separate validation, release drafting and publication
Package publication is a release concern, not a routine CI concern. Pull requests should validate. main should prepare release artifacts. A published GitHub Release should be the only event that uploads packages to nuget.org.
3. Reuse the same build logic across stages
The same reusable build workflow should serve PR validation and main branch release preparation. That prevents drift between “tested code” and “released code” and keeps versioning, packaging and test execution consistent.
4. Publish reviewed artifacts, not freshly rebuilt ones
The packages attached to the draft GitHub Release should be the packages published to nuget.org. Rebuilding during the publication step creates an unnecessary opportunity for mismatch.
5. Keep the login step close to the push step
NuGet’s temporary API key is only valid for a limited time. Authentication should happen after build, test, packaging, artifact download and artifact verification are already complete.
6. Verify artifacts before publication
Before the first dotnet nuget push, the workflow should verify that the expected .nupkg and .snupkg files exist and that the counts match. Small guardrails at this stage prevent avoidable release mistakes.
7. Publish from a dedicated package owner identity
The user configured for NuGet/login should ideally represent the package owner or a dedicated automation identity on nuget.org. Personal accounts create organizational risk when responsibilities change.
8. Keep package ownership and policy ownership aligned
If packages are organization-owned on nuget.org, the trusted publishing policy should also reflect that organizational ownership model. Misalignment between package ownership and policy ownership can cause avoidable operational surprises later.
9. Handle reruns safely
Release pipelines are sometimes rerun after transient failures. --skip-duplicate helps, but rerun behavior should still be understood. A safe rerun policy should define whether symbols, snupkg files, release notes and GitHub Releases are also idempotent.
10. Document the nuget.org policy alongside the workflow
The GitHub workflow alone is not the full configuration. The nuget.org trusted publishing policy is the second half of the system. Recording repository owner, repository name, workflow file and optional environment in project documentation avoids future confusion.
11. Monitor policy activation status
Especially when working with private repositories or new policies, the activation state in nuget.org should be checked after initial setup. A policy that is only temporarily active but never receives a first successful publish can silently expire.
Common Mistakes
A few mistakes appear repeatedly when teams adopt Trusted Publishing for the first time.
One common error is entering the wrong workflow file in nuget.org Trusted Publishing. In a staged setup, the correct file is the actual publish workflow, not the workflow that creates the draft release. Another is forgetting id-token: write, which leads to authentication failures even though the workflow otherwise looks correct. A third is obtaining the temporary credential too early in the workflow and then running into expiration during a later publish step.
There is also an organizational class of failure: the pipeline is configured correctly, but the wrong nuget.org owner or user identity is attached to the policy. In that situation, the workflow may authenticate successfully but still fail to publish the intended package set.
Conclusion
NuGet Trusted Publishing is one of the most practical security improvements available to modern .NET library maintainers. It removes the need for long-lived API keys, reduces operational friction and gives package publishing a stronger, identity-based trust model. The improvement becomes even more compelling when combined with a disciplined release structure.
The recommended workflow design is not “build everything and publish immediately.” A stronger design validates pull requests early, prepares a draft release from main and publishes to nuget.org only when that release is intentionally published. The Unio repository demonstrates why that model works well: it narrows permissions, keeps package artifacts reviewable and makes Trusted Publishing part of a controlled release boundary instead of a generic CI step.
In short, Trusted Publishing is most effective when it is paired with a release process that is explicit, staged and deliberate.
Related articles

Mar 09, 2026 · 7 min read
C# 15 Unions: Unions are finally in .NET
After many years of workarounds, design discussions and library-level substitutes, unions are finally becoming a first-class part of C#. The …

Mar 02, 2026 · 19 min read
Unio: High-Performance Discriminated Unions for C#
C# is a powerful language, but there is one road it has not yet fully paved: native discriminated union types. Developers have been working …

Mar 25, 2024 · 2 min read
Enable NuGet Audit for better DevSecOps in .NET
Auditing is becoming increasingly important in the everyday life of a developer; however, until now there was no particularly good way in …
Let's Work Together
Looking for an experienced Platform Architect or Engineer for your next project? Whether it's cloud migration, platform modernization or building new solutions from scratch - I'm here to help you succeed.

Comments