← Back to list

.NET NuGet Trusted Publishing with GitHub Actions | BEN ABT

Publishing NuGet packages has traditionally required one uncomfortable compromise: a long-lived API key had to exist somewhere in the…

BEN ABT · 2026-03-10 00:00 · 1 claps · 11.5 min read
#dotnet #csharp #nuget #security
Open on Medium ↗
Wiki topics: ☁️ · DevOps & Cloud 🔓 · Open Source

.NET NuGet Trusted Publishing with GitHub Actions | BEN ABT

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:

  1. A GitHub Actions workflow starts.
  2. The workflow requests an OIDC token from GitHub.
  3. The workflow sends that token to nuget.org.
  4. NuGet validates the token and the configured trusted publishing policy.
  5. NuGet returns a temporary API key.
  6. 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:

  1. Pull requests validate build, test and package readiness.
  2. A push to main produces a draft release and attaches the generated NuGet artifacts.
  3. 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.

name: Build and Test (Reusable)

on:
  workflow_call:
    inputs:
      configuration:
        type: string
        default: Release
      upload-test-results:
        type: boolean
        default: false
      create-pack:
        type: boolean
        default: false
    outputs:
      version:
        value: ${{ jobs.build.outputs.version }}

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.nbgv.outputs.SemVer2 }}

    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Setup .NET (stable)
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: |
            8.0.x
            9.0.x
            10.0.x

      - name: Setup .NET (preview)
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: 11.0.x
          dotnet-quality: preview

      - name: Calculate version with NBGV
        id: nbgv
        uses: dotnet/nbgv@master
        with:
          setAllVars: true

      - name: Build and test
        run: >-
          dotnet test
          --configuration ${{ inputs.configuration }}
          --verbosity normal
          --logger "trx;LogFileName=test-results.trx"
          /p:Version=${{ steps.nbgv.outputs.SemVer2 }}

      - name: Pack NuGet packages
        if: inputs.create-pack
        run: >-
          dotnet pack
          --configuration ${{ inputs.configuration }}
          --no-build
          --include-symbols
          -p:SymbolPackageFormat=snupkg
          --output ./artifacts
          /p:PackageVersion=${{ steps.nbgv.outputs.SemVer2 }}

      - name: Upload NuGet packages
        if: inputs.create-pack
        uses: actions/upload-artifact@v4
        with:
          name: nuget-packages
          path: |
            ./artifacts/*.nupkg
            ./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.

name: PR Validation

on:
  pull_request:
    branches:
      - main

permissions:
  contents: read
  pull-requests: read

jobs:
  validate:
    uses: ./.github/workflows/build-and-test.yml
    with:
      upload-test-results: true
      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.

name: Main Build

on:
  push:
    branches:
      - main

jobs:
  build-and-test:
    uses: ./.github/workflows/build-and-test.yml
    with:
      create-pack: true

  create-draft-release:
    needs: build-and-test
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: read

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Download NuGet packages
        uses: actions/download-artifact@v4
        with:
          name: nuget-packages
          path: ./artifacts

      - name: Create draft release
        uses: release-drafter/release-drafter@v6
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          config-name: release-drafter.yml
          version: v${{ needs.build-and-test.outputs.version }}
          tag: v${{ needs.build-and-test.outputs.version }}
          name: Version ${{ needs.build-and-test.outputs.version }}
          publish: false

      - name: Upload packages to draft release
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          TAG="v${{ needs.build-and-test.outputs.version }}"
          for file in ./artifacts/*.nupkg ./artifacts/*.snupkg; do
            [ -e "$file" ] || continue
            gh release upload "$TAG" "$file" --clobber
          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.

name: Publish Release

on:
  release:
    types: [published]

jobs:
  publish-nuget:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read

    steps:
      - name: Download release assets
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          gh release download "${{ github.event.release.tag_name }}" \
            --pattern "*.nupkg" \
            --pattern "*.snupkg" \
            --dir ./artifacts \
            --repo "${{ github.repository }}"

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: |
            8.0.x
            9.0.x
            10.0.x
            11.0.x

      - name: Verify packages
        run: |
          PACKAGE_COUNT=$(ls ./artifacts/*.nupkg | wc -l)
          SYMBOL_COUNT=$(ls ./artifacts/*.snupkg | wc -l)

          if [ "$PACKAGE_COUNT" -eq 0 ] || [ "$SYMBOL_COUNT" -eq 0 ]; then
            echo "Missing package or symbol package artifacts"
            exit 1
          fi

          if [ "$PACKAGE_COUNT" -ne "$SYMBOL_COUNT" ]; then
            echo "Package and symbol package counts do not match"
            exit 1
          fi

      - name: NuGet login (OIDC to temp API key)
        id: login
        uses: NuGet/login@v1
        with:
          user: ${{ secrets.NUGET_USER }}

      - name: Publish to NuGet.org
        run: |
          for package in ./artifacts/*.nupkg; do
            dotnet nuget push "$package" \
              --api-key "${{ steps.login.outputs.NUGET_API_KEY }}" \
              --source https://api.nuget.org/v3/index.json \
              --skip-duplicate
          done

          for symbol in ./artifacts/*.snupkg; do
            dotnet nuget push "$symbol" \
              --api-key "${{ steps.login.outputs.NUGET_API_KEY }}" \
              --source https://api.nuget.org/v3/index.json \
              --skip-duplicate
          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:

  1. A reusable workflow owns build, test, versioning and optional packaging.
  2. Pull requests use that workflow to validate releasability before merge.
  3. main uses the same workflow to produce versioned artifacts and a draft release.
  4. 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.

Author

BEN ABT

Ben is a Principal Software Engineer at Medialesson.de, with a strong focus on .NET and Microsoft Azure. In his professional role, he designs and builds highly scalable platforms for hybrid and cloud-based scenarios. He also advises companies and C-levels on migrating to the sovereign cloud.

He runs myCSharp.de, the largest and most active German-speaking C# community forum, actively contributes to both his own and external open-source projects, and regularly shares his knowledge through his blog, at community events, conferences, and as a writer for technical magazines.

Originally published at https://benjamin-abt.com on March 10, 2026.


메타데이터
post_id
5331e52dc7db
slug
net-nuget-trusted-publishing-with-github-actions-ben-abt-5331e52dc7db
url
https://medium.com/@benjaminabt/net-nuget-trusted-publishing-with-github-actions-ben-abt-5331e52dc7db
canonical_url
https://medium.com/@benjaminabt/net-nuget-trusted-publishing-with-github-actions-ben-abt-5331e52dc7db
author_url
https://medium.com/@benjaminabt
status
ok
fetched_at
2026-06-26 21:52:29