Guide

How to enforce a NuGet package cooldown

A dependency cooldown is a simple rule: don't install a package version until it has been public for a minimum number of days. It is one of the cheapest supply-chain measures available, and yet NuGet has no native way to enforce one at build time. This guide explains the gap, the ecosystem trend that filled it everywhere else, and how to close it for .NET today with NuGate, an open-source tool that enforces a dependency age policy for your restore graph.

The problem: fresh versions are the dangerous ones

Modern supply-chain attacks against package registries share a shape. An attacker compromises a maintainer account or a publishing token, pushes a poisoned version of an otherwise reputable package, and races the clock. The malicious version does its damage in its first hours and days — before a researcher notices, before the registry unlists it, before an advisory is filed. Once the version is yanked and the maintainer rotates credentials, the window closes. The whole attack lives in the freshness of the release.

The clearest recent case study is the Shai-Hulud worm on npm. Beginning in September 2025, a self-replicating worm used stolen npm tokens to inject a post-install script into hundreds of packages; the script harvested credentials and used the maintainer's own publishing rights to push freshly poisoned versions of every other package they owned. Initial counts of roughly 187 affected packages were later revised past 500 compromised versions. The entry point was mundane: a routine update pulled a compromised version that had been public for only a short time.

That last detail is the whole story. Organizations that pulled the bad versions on publication day were exposed; the cost of cleanup — rotating every credential the build machine could touch, auditing lateral movement, rebuilding trust in the dependency tree — routinely runs to a week or more of lost engineering time. Teams whose tooling simply hadn't reached for those versions yet, for any reason, were spared. The difference between the two groups was not detection. It was latency.

The insight

Compromised versions are almost always reported and pulled within days of publication. If your build never resolves a version until it has survived in public for a week, the great majority of these incidents age out before they can ever reach you. That delay is the entire mechanism — no scanning, no signatures, no feed to subscribe to.

Why the existing options don't cover it

.NET teams reaching for a cooldown today find four partial answers, and a gap in the middle where enforcement should be.

NuGet itself doesn't enforce one yet. The feature request lives at NuGet/Home #14657, “Add update cooldown option to NuGet.” As of this writing it is open, labelled Priority:2 on the backlog, with no assignee and no linked pull requests. It is a good issue and worth your thumbs-up — until it lands natively, though, there is nothing in dotnet restore or update that will hold a too-young package back.

Dependabot and Renovate gate bot pull requests, not people. Dependabot shipped a minimum package age option in July 2025 and expanded it to NuGet later that month; Renovate has offered the same idea for years, now spelled minimumReleaseAge (renamed from the older stabilityDays). Both are genuinely useful, but they only delay the updates they themselves propose. A developer who runs dotnet add package by hand, or bumps a version in a .csproj during a feature branch, walks straight past a bot cooldown. That manual path is exactly the vector in most account-takeover incidents.

Advisory tools filter suggestions; they enforce nothing. dotnet-outdated and friends can hide too-young versions from the list of upgrades they recommend, but they are opt-in helpers. Nothing about them fails a build or blocks a restore.

Registry proxies work, but they are infrastructure. Artifactory, Sonatype Nexus, and escrow-style OSS proxies can hold packages back at the feed layer. That is a real, robust answer — and it costs you a proxy to stand up, secure, and point every developer machine and CI runner at. For a team pulling directly from nuget.org, it is a large lift for one policy.

The hole in the middle

Every option above either gates only the bots, only suggests, or demands new infrastructure. None of them fail a normal build when a human — or a transitive dependency — pulls a package that is too young. That restore-native, everyone-included enforcement is the gap NuGate fills.

The cooldown concept — and why every other ecosystem now has one

The idea has a name now, and it is converging on the same vocabulary across languages: a minimum release age. You pick a number of days; the package manager refuses to resolve any version younger than that, treating the latest acceptable version as the newest one old enough to trust. It is a floor on freshness, applied at install time, transitive dependencies included.

The JavaScript ecosystem adopted it in a rush through late 2025 and into 2026. pnpm shipped minimumReleaseAge (measured in minutes) in version 10.16 and made a one-day default in pnpm 11. npm followed with min-release-age in 11.10.0. Yarn and Bun added their own equivalents in the same window. The Python installer uv has an exclude-newer flag in the same spirit. cooldowns.dev now tracks the feature ecosystem by ecosystem — and lists NuGet among those with no native option.

So the concept is settled and proven; the only thing missing for .NET is an enforcement point. NuGate provides one that lives exactly where your dependencies actually get resolved: the restore graph.

Enforcing a cooldown with NuGate, step by step

NuGate reads the resolved dependency graph after restore, looks up each package version's immutable catalog created timestamp from nuget.org, and fails when anything is younger than your policy. It ships in two forms that cover the two places a too-young package gets in: the dev machine and CI. Use both.

1. Gate developer builds with NuGate.Build

NuGate.Build is an MSBuild task delivered as a NuGet package. Add one PackageReference to a Directory.Build.props at the root of your repository and it applies to every project in the solution at once. PrivateAssets="all" keeps it out of your own package's dependency list.

Directory.Build.props
<Project>
  <ItemGroup>
    <PackageReference Include="NuGate.Build" Version="0.1.2" PrivateAssets="all" />
  </ItemGroup>
</Project>

After the next restore, the task hooks in and reads obj/project.assets.json — the fully resolved graph, so transitive dependencies are covered, not just the ones you named. If everything clears the policy, the build is silent. If something doesn't, the build fails with the offending package, how it was pulled in, its age, and the exact snippet to override it:

dotnet build
$ dotnet build
  Determining projects to restore...
  Restored C:\src\Vantage.Orders\Vantage.Orders.csproj (in 1.8s).
  NuGate.Build 0.1.0 — checking resolved package ages against nugate.json...

C:\src\Vantage.Orders\Vantage.Orders.csproj : error NUGATE001:
  Package 'Northwind.Http.Retry' 2.4.1 fails the dependency age policy.

    Resolved via: Vantage.Orders.Api -> Vantage.Orders.Core -> Northwind.Http.Retry 2.4.1 (transitive)
    Published (catalog 'created'): 2026-07-13T02:41:09Z
    Age at build time: 2.1 days
    Policy: minAgeDays = 7 (nugate.json, mode: enforce)

  To allow this version explicitly, add to nugate.json:

    {
      "id": "Northwind.Http.Retry",
      "version": "2.4.1",
      "expires": "2026-08-01",
      "reason": "hotfix"
    }

Build FAILED.

    1 Error(s)

Time Elapsed 00:00:04.62

2. Gate CI with nugate check

On CI, run the check as an explicit step between restore and build. There is a good reason it is a separate tool rather than only an MSBuild task: a malicious package can carry a .targets file that executes during the build itself. Running nugate check before dotnet build evaluates the graph before any project-supplied build logic runs.

bash
dotnet tool install -g NuGate.Tool
dotnet restore
nugate check
dotnet build --no-restore

The tool scans every project.assets.json and packages.lock.json under the working tree, exits non-zero on a violation, and can emit machine-readable JSON for pipeline annotations. The bundled GitHub Action wraps these three lines so you can drop it into a workflow without hand-writing the install step.

3. Configure the policy in nugate.json

Both artifacts read the same nugate.json at the repository root. A minimal, fail-closed policy is just a few lines:

nugate.json
{
  "minAgeDays": 7,
  "mode": "enforce",
  "onApiFailure": "fail",
  "allow": [],
  "exemptPrefixes": ["MyCompany."]
}

minAgeDays is your cooldown — seven days is the widely cited sweet spot. onApiFailure defaults to "fail": if nuget.org can't be reached to confirm a timestamp, the build fails rather than waving the package through. That is the safe default, but if your team won't tolerate a registry outage breaking builds, "warn" is the documented opt-out. exemptPrefixescovers internal packages from a private feed, whose timestamps don't live on nuget.org.

4. Handle the exceptions with an allowlist

Sometimes you genuinely need a version that hasn't aged yet — an urgent hotfix, say, whose diff you've read yourself. Rather than drop the policy, add a scoped, expiring entry to allow. The failure output prints the exact snippet, so this is usually a copy-paste:

nugate.json
{
  "minAgeDays": 7,
  "mode": "enforce",
  "onApiFailure": "fail",
  "allow": [
    {
      "id": "Northwind.Http.Retry",
      "version": "2.4.1",
      "expires": "2026-08-01",
      "reason": "hotfix — reviewed diff, pinned deliberately"
    }
  ],
  "exemptPrefixes": ["MyCompany."]
}

The expiresdate is the important part: it forces the exception to lapse once the version has aged past your policy on its own, so allowlist entries don't quietly fossilize into permanent holes. The reason is there for the next person reading the diff in code review.

5. Roll it out with warn first

Turning on a hard gate across an existing repo can surface a surprising number of recently-bumped packages at once. Start in warn mode so violations are reported without failing anything, work through the list — most will simply age out within your cooldown window — then switch mode to enforce once the noise settles.

nugate.json (rollout)
{
  "minAgeDays": 7,
  "mode": "warn"
}

Full configuration reference and installation details live in the GitHub repository.

What a cooldown does not do

A dependency age policy is a blunt, honest instrument, and it is worth being precise about its edges — the same way you'd want any tool in your build to be precise about its own.

Read this part

NuGate enforces a dependency age policy. It does not inspect package contents, and it detects no malware. A version that was compromised but has been public for longer than your minAgeDays passes the gate by design — the policy is about age, nothing else.

A cooldown shrinks the window in which a freshly published version can reach your build. It does not close that window, and it is not a substitute for vulnerability scanning, lockfile review, or least- privilege credentials on your build machines. Think of it as one cheap, high-leverage layer, not a perimeter.

There are a couple of mechanical caveats worth knowing. NuGate reads the immutable catalog created timestamp rather than the mutable published field on purpose: nuget.org resets published to 1900-01-01 when a package is unlisted, and compromised versions get unlisted after takedown — so an age check naïvely trusting publishedwould wave the very worst packages through as “ancient.” Reading createdavoids that trap. And packages from private feeds aren't on nuget.org at all, which is what exemptPrefixes is for.

None of that changes the value proposition, which is deliberately modest. Most supply-chain incidents against package registries are caught within days. Enforce a cooldown and you decline, as a matter of build policy, to be among the first to run brand-new code from the internet. For a one-line install, that is a good trade — and until #14657lands natively, it's the trade NuGate exists to make.

Ready to set it up? The install snippets and full config reference are on the NuGate home page, and the source is on GitHub.