A few months ago a client asked me to help answer a simple question: "how many npm packages does our company currently publish to the public registry?" Their gut answer was "around forty." The actual answer was 213, and eleven of them were packages that had been abandoned years ago, one of which contained a hardcoded internal API endpoint. This is not unusual. Package visibility drifts over time, and without a deliberate audit process, no one notices until something embarrassing happens.
This post is a field guide to auditing npm package visibility at scale. It is less a security theory piece and more a collection of techniques that work in practice.
What "Visibility" Actually Means
An npm package has three relevant visibility states. It can be public (installable by anyone without auth), private to a specific organization (installable only by members of that org with the right permissions), or unpublished (tarballs no longer served; npm policy since 2016 tightly restricts unpublish).
Visibility is set at publish time via the access field in publishConfig or via the --access CLI flag. Scoped packages default to private on publish; unscoped packages default to public. This default is the source of the most common visibility mistake I see: a developer creates an internal utility without a scope, runs npm publish, and the package is immediately public.
The npm access CLI (now npm access list and npm access set in npm 9+) lets you inspect and change visibility after the fact. Making a public package private is possible; making a private package public is possible; unpublishing is only possible within 72 hours of publish, and only if no other public package depends on it.
The Inventory Problem
Before you can audit visibility, you need to know what packages exist. For a single npm org this is easier than it sounds.
The unauthenticated endpoint https://registry.npmjs.org/-/org/<org>/package returns the list of packages in an organization and each package's role (developer, maintainer, owner). This is the authoritative starting point. For a multi-org setup, repeat for each org. The list does not distinguish between public and private packages at this endpoint; you need a second call, https://registry.npmjs.org/<package-name>, which returns 404 for a private package you don't have access to, 200 with full metadata for a public package, and 200 with metadata when authenticated as a member of the owning org.
I wrote a small Go script for a client in late 2023 that iterated the org packages endpoint, made the secondary call for each, and produced a CSV with columns: name, latest_version, is_public, last_publish_date, maintainer_count, weekly_downloads. The CSV for their 213 packages took about forty seconds to produce. That CSV is the foundation of every subsequent audit.
Packages Outside The Org
The trickier case is packages published by employees to personal scopes (@username/...) or to no scope at all, using an npm account that happens to be associated with a company email. Npm's API does not give you a clean way to find these. The best I have done is cross-referencing the HR directory's employee emails against npm user profiles (npm user profiles have public emails by default). This turns up at least a few packages every time. One client's general counsel was unthrilled to learn that a former employee had published a library containing their company's customer list parser to a personal scope in 2019. The package is still up.
Visibility Drift Patterns
I've seen four patterns of visibility drift.
The first is the default-public accident mentioned above. A scoped package prevents this, so enforcing @yourcompany/ scoping on all internal packages is the cheapest mitigation. A root .npmrc that sets access=restricted and is inherited by all internal projects helps too.
The second is the intentional-public-gone-stale case. A library was published as open-source because it was genuinely useful and someone cared about the community. Five years later the library is abandoned, the maintainer has left the company, and the README says "contact foo@company.com" for an email that no longer routes. These packages should either be transferred to a named maintainer still in the company or deprecated with npm deprecate. Leaving them public and unmaintained is a reputational risk and a supply-chain risk if the abandoned package is still being installed.
The third pattern is the private-now-public mistake: a package was private, someone ran npm access set status=public to debug a permissions issue, and forgot to revert. I have seen this happen three times, always in a Friday-afternoon debugging session.
The fourth is the orphaned-public package: a public package whose sole maintainer is an ex-employee. If that ex-employee's npm account was compromised years later, the package could be maliciously updated. The npm security team can intervene in some cases, but prevention is easier than recovery.
The Secrets-In-Public-Packages Check
Whenever I audit visibility, I also scan public packages for accidentally-embedded secrets. The .npmignore and files field in package.json control what ends up in the published tarball, but both have sharp edges. .npmignore, if present, replaces .gitignore, so if you added a new secret to .gitignore but didn't update .npmignore, it can ship. The files field allowlist is more reliable, but only if it is actively maintained.
In January 2024 I ran trufflehog filesystem against the unpacked tarballs of 213 packages for a client. Two contained AWS access keys. One contained a GitHub PAT scoped to read a private repo. All three had been public for over a year. The AWS keys were rotated; the damage was, as far as the client could tell from CloudTrail, zero, but the fact that both keys had not been used since their accidental publication does not prove they were never seen.
A simpler mitigation: the npm publish --dry-run command lists exactly what will be in the tarball. Every new publish of an externally-visible package should require a human to review that list.
Re-Visibility As A Policy Gate
For internal packages, enforce scoping at the policy layer. A pre-publish hook that checks package.json for a scope matching @yourcompany/ and blocks publish otherwise is five lines of shell. Add it to the base Docker image that your CI uses for publishes. This catches every scope mistake before it can hit the registry.
For legitimately-public packages, require a review before first publish and before any change in scope or access level. The review should cover: what the package exposes, what its maintenance plan is, whether the README identifies a current maintainer, and whether the tarball has been inspected for secrets.
How Safeguard Helps
Safeguard inventories every npm package your organization publishes and surfaces the full state: public or private, last publish date, maintainer list, dependency count, and any secrets embedded in the tarball. We flag drift (a package that changed from private to public, or an abandoned maintainer profile) and block new publishes that violate your scoping policy through the CI integration. For orphaned packages whose only maintainer has left the company, Safeguard generates the transfer or deprecation action plan and routes it to the right owner. If you want a quarterly visibility report that is actually accurate, Safeguard produces it against live registry data rather than a stale spreadsheet.