Setting an npm minimum release age is, for me, the cheapest security measure you can add to a JavaScript project this week. One line in your .npmrc, and npm only installs package versions that are already a few days old. That sounds trivial, but it targets exactly the moment when supply chain attacks hit.
We have seen the pattern plenty of times since 2025: someone takes over a maintainer account, publishes a version with malware, and it stays available for a few hours up to a few days until scanners or the community report it and the registry removes it. Anyone who runs npm install during that window gets it. That might be your laptop, your CI pipeline or an AI agent that “quickly” adds a dependency. A cooldown changes only one thing: you are no longer among the first. On top of that, GitHub put a package firewall with exactly this kind of waiting period into preview on October 20. High time to set this up properly.
In short
A cooldown only lets new package versions into your project after a waiting period: in npm via
min-release-age, in pnpm, Yarn and Bun via their own settings with different units, and in Dependabot and Renovate at the level of update PRs. The key finding from my test:npm ciand annpm installwith an existing lockfile do not check the age, so a lockfile check belongs in CI. The biggest pitfall is urgent security fixes, for which you need a deliberate exception path.
What you will learn in this article
- How npm min-release-age works and how to set it up in five minutes
- Which settings pnpm, Yarn and Bun offer and why the units are tricky
- Why
npm cibypasses the cooldown and how a lockfile check catches it - How to configure Dependabot and Renovate to match the waiting period
- How to ship an urgent security fix right away anyway
What does the npm minimum release age do?
Before npm installs anything, it builds a dependency tree: for every range such as "zod": "^4.1.0" it looks for the newest matching version. With min-release-age=3 in the configuration, only versions published more than three days ago qualify. If there is a matching older version, npm simply takes that one. If there is none, the command fails with an error. The option was introduced in npm 11.10.0 in February 2026.
There is also the older option before. It takes a fixed date instead of a number of days. If both are set in the same configuration source, before wins according to the npm docs. Across sources the usual order applies: command line before environment variable before project .npmrc before user and global configuration. That matters later for the exception path.
One more term, because it comes up constantly: the lockfile (for npm, the package-lock.json) records which version of each package was actually installed, including all indirect dependencies. If you commit it, every machine gets the same tree.
🎬 Cooldown: a waiting period for new package versions – this animated diagram only runs on my site: watch it on sascha-ms.de
Three layers for a cooldown
You can add the waiting period in three places, and each one protects something different. So it is worth a quick look at the big picture before you touch any configuration files.
Four places where package versions that are too new get stopped
- Package manager (npm, pnpm, Yarn, Bun): applies whenever versions are resolved again, i.e. with
npm install package,npm updateor a fresh lockfile. - Update bot (Dependabot, Renovate): applies before a pull request with a new version even exists.
- Registry proxy (GitHub package firewall or your own proxy): applies centrally to every machine that installs through it, including CI runners and agents whose configuration you do not control.
For most teams the first two layers plus a check in CI are enough. The third one is mainly interesting for larger organizations, more on that below.
How to set up npm min-release-age
Prerequisites
- npm 11.10.0 or newer for
min-release-age, npm 12 for the exclusion listmin-release-age-exclude. Check withnpm -v, update withnpm install -g npm@12. - A project with a committed
package-lock.json. - For the lockfile check further down: Node.js 22.18 or newer (TypeScript files run there without a build step).
Step 1: Create the .npmrc in your project
Create an .npmrc in the project folder or extend the existing one. The exclusion for your own scope is optional but sensible: you do not want to block internal packages that your team publishes itself for three days.
# .npmrc min-release-age=3 min-release-age-exclude[]=@bookshop/* Commit the file. That way the rule applies to everyone on the team and in CI, not just on your machine. If you also want the protection for all your own projects, put the same line into your user configuration (npm config set min-release-age 3).
Step 2: Test whether the waiting period applies
The easiest test uses a package that publishes new versions every day. TypeScript ships a nightly build under the next tag. Explicitly request a version from today:
npm install typescript@7.1.0-dev.20261028.1 In my test with npm 12.2.1, I got exactly what you want to see:
npm error code ETARGET npm error notarget No matching version found for typescript@7.1.0-dev.20261028.1 with a date before 10/25/2026, 9:38:42 AM. If you only ask for typescript@next, on the other hand, npm silently resolves to an older version. In my case the stable 7.0.2 ended up in the project instead of today’s nightly build. That is convenient, but it can surprise you when you deliberately want to test a prerelease.
Step 3: Define an exception path for hotfixes
Sooner or later a security fix appears that you do not want to leave lying around for three days. Because the command line overrides the project configuration, this is enough for a single, consciously reviewed case:
npm install ky@latest --min-release-age=0 Watch out for npm audit fix: if the cooldown blocks the patched release, npm keeps the vulnerable version according to the docs, prints a warning and exits with an error code. A pipeline that runs npm audit fix automatically will fail in that case. That is intended, but your team should know what to do in that moment.
pnpm, Yarn and Bun compared
The other package managers partly had the idea earlier and name it differently. The units are the real danger: the same number 3 means three days in npm, three minutes in pnpm and three seconds in Bun.
| Package manager | Setting | Unit | Exclusions |
|---|---|---|---|
| npm | min-release-age in .npmrc | days | min-release-age-exclude (npm 12 and later) |
| pnpm | minimumReleaseAge in pnpm-workspace.yaml | minutes (default 1440 per current docs) | minimumReleaseAgeExclude |
| Yarn (Berry) | npmMinimalAgeGate in .yarnrc.yml | duration, e.g. 1w | npmPreapprovedPackages |
| Bun | install.minimumReleaseAge in bunfig.toml | seconds | minimumReleaseAgeExcludes |
# pnpm-workspace.yaml minimumReleaseAge: 4320 # 3 days in minutes minimumReleaseAgeExclude: - '@bookshop/*' # bunfig.toml [install] minimumReleaseAge = 259200 # 3 days in seconds minimumReleaseAgeExcludes = ["@bookshop/prices"] With pnpm, two neighboring settings are worth a look as well: minimumReleaseAgeStrict is enabled automatically as soon as you set minimumReleaseAge yourself, and it makes the installation fail when no version is old enough. minimumReleaseAgeIgnoreMissingTime, however, defaults to true. If a private registry does not return a publish date, the package is waved through without a check.
The gap: npm ci does not check the age
Now comes the part that kept me busy the most while trying this out. I created a lockfile without a cooldown that contained today’s TypeScript nightly. After that I set min-release-age=3 and reinstalled:
rm -rf node_modules npm ci npm install Both commands ran without errors, and the folder still contained the version from that same morning. This is not a bug. The lockfile is a decision that has already been made, and the cooldown only applies when npm resolves versions again. As a result, a lockfile with very new versions can reach your repository in several ways:
- Someone still works with npm 10 or an early npm 11 version. Those do not know the option and ignore it. In my test, npm 10.9 installed the nightly without any comment.
- A bot or AI agent updates the lockfile with its own configuration.
- Someone bypasses the rule locally with
--min-release-age=0and forgets to mention it in the review.
The consequence: the waiting period belongs not only in the .npmrc but also in CI as a check. Specifically, for the versions a pull request newly adds to the lockfile.
A lockfile check in CI with TypeScript
The idea is simple: the script compares the pull request’s lockfile with the one from the target branch, collects all new or changed package versions and asks the registry for their publish dates. The npm registry provides them in the time field of the package metadata (the so-called packument). If a version is newer than allowed or the date is missing, the check fails. The script has no runtime dependencies and runs directly as TypeScript on Node.js 22.18 or newer.
Step 1: Create the project
Create a folder tools/release-age-guard in the repository. You only need TypeScript for type checking; at runtime Node strips the types itself. That is why the tsconfig.json contains erasableSyntaxOnly: it makes the compiler report syntax that Node cannot simply drop, such as enum.
{ "name": "release-age-guard", "version": "1.0.0", "private": true, "type": "module", "engines": { "node": ">=22.18" }, "scripts": { "check": "node src/cli.ts", "typecheck": "tsc --noEmit", "test": "node --test" }, "devDependencies": { "@types/node": "^22.18.0", "typescript": "^7.0.2" } } { "compilerOptions": { "target": "es2023", "module": "nodenext", "moduleResolution": "nodenext", "strict": true, "noEmit": true, "allowImportingTsExtensions": true, "erasableSyntaxOnly": true, "verbatimModuleSyntax": true, "types": ["node"] }, "include": ["src", "test"] } Step 2: Read and compare the lockfile
In a package-lock.json (version 2 or 3), the packages section lists every installed path, for example node_modules/a/node_modules/zod. The package name is everything after the last node_modules/. The function skips workspace folders, links and local paths because they do not come from the registry.
// src/lockfile.ts export interface LockedPackage { name: string; version: string; } interface LockfileV3 { lockfileVersion?: number; packages?: Record<string, { version?: string; link?: boolean; resolved?: string }>; } // Reads all installed packages from a package-lock.json (lockfileVersion 2 or 3). export function readLockedPackages(json: string): LockedPackage[] { const lock = JSON.parse(json) as LockfileV3; if (!lock.packages) { throw new Error('package-lock.json without "packages" – please use lockfileVersion 2 or 3'); } const result = new Map<string, LockedPackage>(); for (const [path, entry] of Object.entries(lock.packages)) { // Installed packages only; skip workspace folders and links if (!path.includes('node_modules/') || entry.link || !entry.version) continue; // Local paths and git dependencies have no registry tarball if (entry.resolved && !entry.resolved.startsWith('http')) continue; const name = path.slice(path.lastIndexOf('node_modules/') + 'node_modules/'.length); result.set(`${name}@${entry.version}`, { name, version: entry.version }); } return [...result.values()]; } // Returns only packages that are new or have a different version in the new lockfile. export function changedPackages(base: LockedPackage[], head: LockedPackage[]): LockedPackage[] { const known = new Set(base.map((p) => `${p.name}@${p.version}`)); return head.filter((p) => !known.has(`${p.name}@${p.version}`)); } Step 3: Calculate the age and collect violations
The actual rule lives in a pure function. It receives the source of the date as a function argument. This lets you test it without a network. Exclusions work like in npm: exact names or a scope with /*.
// src/release-age.ts import type { LockedPackage } from './lockfile.ts'; export type PublishTimeLookup = (pkg: LockedPackage) => Promise<Date | undefined>; export interface AgeViolation { pkg: LockedPackage; ageDays: number | undefined; // undefined = the registry has no publish date } export interface AgeCheckOptions { minDays: number; exclude: string[]; // package names or patterns like "@bookshop/*" now?: Date; } const DAY_MS = 24 * 60 * 60 * 1000; export function isExcluded(name: string, patterns: string[]): boolean { return patterns.some((pattern) => pattern.endsWith('/*') ? name.startsWith(pattern.slice(0, -1)) : name === pattern, ); } export async function findTooYoung( packages: LockedPackage[], lookup: PublishTimeLookup, options: AgeCheckOptions, ): Promise<AgeViolation[]> { const now = options.now ?? new Date(); const candidates = packages.filter((pkg) => !isExcluded(pkg.name, options.exclude)); const checks = await Promise.all( candidates.map(async (pkg) => { const published = await lookup(pkg); if (!published) return { pkg, ageDays: undefined }; const ageDays = (now.getTime() - published.getTime()) / DAY_MS; return { pkg, ageDays }; }), ); // A missing date counts as a violation: better to look once too often return checks.filter((c) => c.ageDays === undefined || c.ageDays < options.minDays); } Step 4: Query the registry
For each package the script loads the metadata exactly once, even if several versions of it appear in the lockfile. Scoped names such as @bookshop/prices need an encoded / in the URL. If your team uses its own registry, pass it with --registry.
// src/registry.ts import type { LockedPackage } from './lockfile.ts'; interface Packument { time?: Record<string, string>; } // Asks the registry for the publish date of exactly this version. export function createRegistryLookup(registry = 'https://registry.npmjs.org') { const cache = new Map<string, Promise<Packument>>(); const base = registry.replace(/\/$/, ''); const load = (name: string): Promise<Packument> => { let pending = cache.get(name); if (!pending) { const url = `${base}/${name.replace('/', '%2F')}`; pending = fetch(url, { headers: { accept: 'application/json' } }).then(async (res) => { if (!res.ok) throw new Error(`Registry answered ${res.status} for ${name}`); return (await res.json()) as Packument; }); cache.set(name, pending); } return pending; }; return async (pkg: LockedPackage): Promise<Date | undefined> => { const doc = await load(pkg.name); const iso = doc.time?.[pkg.version]; return iso ? new Date(iso) : undefined; }; } Step 5: The command line script
The script reads the minimum age from the .npmrc, so there is only one source of truth. You can override it with --min-days.
// src/cli.ts import { readFileSync, existsSync } from 'node:fs'; import { parseArgs } from 'node:util'; import { readLockedPackages, changedPackages } from './lockfile.ts'; import { findTooYoung } from './release-age.ts'; import { createRegistryLookup } from './registry.ts'; const { values } = parseArgs({ options: { base: { type: 'string' }, // lockfile from the target branch, e.g. from git show head: { type: 'string', default: 'package-lock.json' }, 'min-days': { type: 'string' }, exclude: { type: 'string', multiple: true, default: [] }, registry: { type: 'string', default: 'https://registry.npmjs.org' }, }, }); // Without --min-days, the value from the project .npmrc applies function minDaysFromNpmrc(): number | undefined { if (!existsSync('.npmrc')) return undefined; const match = readFileSync('.npmrc', 'utf8').match(/^\s*min-release-age\s*=\s*(\d+)/m); return match ? Number(match[1]) : undefined; } const minDays = Number(values['min-days'] ?? minDaysFromNpmrc() ?? 3); const head = readLockedPackages(readFileSync(values.head, 'utf8')); const base = values.base ? readLockedPackages(readFileSync(values.base, 'utf8')) : []; const toCheck = changedPackages(base, head); console.log(`Checking ${toCheck.length} new or changed packages (minimum age ${minDays} days)`); const violations = await findTooYoung(toCheck, createRegistryLookup(values.registry), { minDays, exclude: values.exclude, }); for (const {Source:https://dev.to/sascha-ms/npm-minimum-release-age-a-cooldown-for-dependencies-7llRelated posts
03/04/2026: Why Date Formats Put Calendar Events in the Wrong Week

