How to Avoid Shipping the Wrong Files to npm
Prevent npm from shipping unintended files or secrets. Understand how .npmignore, .gitignore, and package.json files interact, and verify tarballs with npm pack --dry-run.
If your package has an .npmignore file, npm stops reading .gitignore when it decides what to leave out of the tarball. That holds even when the .npmignore file is empty.
Most people find this out when a tarball shows up far larger than expected, or when a dotfile they never meant to publish appears on the registry. The ignore rules looked fine, and they were fine until someone added a second ignore file.
This article covers the traps that put the wrong files in your package: the .gitignore fallback, the way files and .npmignore interact, npm’s fixed include and exclude lists, and what to do once a bad version is already live. It assumes you have read How to Create and Publish an npm Package.
Key Takeaways
- An
.npmignorefile, even an empty one, turns off every.gitignorerule npm would otherwise apply to your tarball. - npm reads
.npmignoreand.gitignorein every subdirectory, so an ignore file deep in the tree can change what gets published. - npm’s built-in exclusions do not cover
.envfiles, private keys or certificates. - If a secret reaches the registry, rotate it first. Unpublishing does not recall copies that were already downloaded.
- Provenance records which repository, commit and workflow built a version. It does not prove the code is safe.
The npmignore Trap: One New File Drops Every gitignore Rule
npm uses either .npmignore or .gitignore to decide what to leave out of a package, never both. npm’s rules for keeping files out of a package are short. With no .npmignore, npm applies .gitignore. Once any .npmignore exists, .gitignore no longer counts. The two files are never merged.
The usual trigger is build output. dist/ is gitignored, so npm leaves it out of the tarball. You add an .npmignore so that dist/ ships. dist/ now ships, and so does everything else .gitignore used to hide: .env.local, coverage/ and test/fixtures/.
The root folder is not the only place npm looks. It picks up both kinds of ignore file from any folder inside the package, so a stray .npmignore in a nested folder can change what gets published without anyone touching the root config.
Before and After: The File List Changes
The script below builds a package where .gitignore does all the filtering:
mkdir pkg-trap && cd pkg-trap
npm init -y >/dev/null
mkdir -p src dist test/fixtures coverage
echo 'module.exports = 1' > src/index.js
echo 'module.exports = 1' > dist/index.js
echo 'API_KEY=replace-me' > .env.local
echo '{}' > test/fixtures/big.json
echo 'x' > coverage/lcov.info
printf 'dist/\n.env.local\ncoverage/\ntest/fixtures/\n' > .gitignore
npm pack --dry-run
npm lists the following (paths only, trimmed):
package.json
src/index.js
dist/ is missing, so you add a one-line .npmignore to ship the build and drop the source:
echo 'src/' > .npmignore
npm pack --dry-run
package.json
- src/index.js
+ dist/index.js
+ .env.local
+ coverage/lcov.info
+ test/fixtures/big.json
The fix is to copy every rule you still need into .npmignore:
printf 'src/\n.env*\ncoverage/\ntest/\n*.map\n' > .npmignore
files vs .npmignore: Allow-List or Deny-List
The files field in package.json is an allow-list and .npmignore is a deny-list. If you use both, no single file tells you what your package contains.
| Mechanism | Model | A new file you forget about |
|---|---|---|
files | Allow-list | Stays out |
.npmignore | Deny-list | Ships |
.gitignore (fallback) | Deny-list | Ships, unless it is also gitignored |
The overlap between files and .npmignore is where problems start. According to the package.json docs, a .npmignore in the package root cannot take away anything files has added. A .npmignore inside a subfolder can:
{ "name": "my-lib", "version": "1.0.0", "main": "dist/index.js", "files": ["dist"] }
# dist/.npmignore
*.map
Here files brings in dist/, and the nested ignore file filters sourcemaps out of it. Two files now define one list. Build tools that write their own output folders make this worse, so check what ends up in dist/ if you follow our pnpm guide. The simplest rule is to pick one mechanism and delete the other.
What npm Always Includes and Always Excludes
Some files are included or excluded whatever you configure. According to the package.json docs, npm always includes package.json, the README, LICENSE or LICENCE, the file named in main and any files named in bin. npm always excludes version-control folders, .npmrc, node_modules and editor clutter. Secrets are not on that list.
| Always included | Always excluded (non-exhaustive) | Not excluded unless you say so |
|---|---|---|
package.json, README (and variants), LICENSE / LICENCE, the main file, the bin file(s) | .git, .npmrc, node_modules (except bundled deps), .DS_Store, npm-debug.log, config.gypi, .*.swp, *.orig, package-lock.json | .env, .env.local, *.pem, *.key, fixtures, coverage |
Add a guard for the right-hand column:
npm pack --dry-run 2>&1 | grep -E '\.env|\.pem$|\.key$' && { echo "STOP: secrets in tarball"; exit 1; }
Run npm pack —dry-run Before Every Publish
npm pack --dry-run prints the exact list of files npm would publish without writing a tarball, so it catches every trap above. Run it after you change any ignore file, the files field or your build config, and put it in CI ahead of npm publish. The basic workflow is covered in How to Create and Publish an npm Package.
npm pack --dry-run
What to Do if You Already Published the Wrong Files
If a secret reaches the registry, rotate it first and treat it as public for good. Unpublishing a version does not remove copies that were already downloaded or mirrored. After that, clean up the registry:
- Rotate the credential with its provider.
- Remove or flag the bad version. Under npm’s unpublish policy, you can unpublish within 72 hours of publishing, as long as no other public package depends on it. After that window, only packages that meet the policy’s criteria can be unpublished. Otherwise, use
npm deprecate. - Ship a clean patch. A version number that has been used can never be reused, even after it is unpublished.
# Within 72 hours, subject to npm's unpublish policy
npm unpublish my-lib@1.4.2
# Otherwise
npm deprecate my-lib@1.4.2 "Contains a leaked file. Upgrade to 1.4.3."
# Fix the ignore rules, confirm, republish
npm pack --dry-run
npm version patch
npm publish
Provenance Proves Origin, Not Safety
An npm provenance statement records which repository, commit and CI workflow built a package version. It does not show that the code in that commit is safe, or that the tarball holds only the files you intended. In GitHub Actions, the workflow needs id-token: write:
name: publish
on:
release:
types: [published]
permissions:
contents: read
id-token: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
registry-url: https://registry.npmjs.org
- run: npm ci
- run: npm pack --dry-run
- run: npm publish
This workflow assumes you have set up trusted publishing. Trusted publishing needs npm CLI 11.5.1 or later and Node 22.14.0 or later, and it generates provenance automatically, so the job needs neither --provenance nor NODE_AUTH_TOKEN. If you publish with a token instead, npm publish --provenance requires npm 9.5.0 or later. Either way, the repository field in package.json must match the publishing repo exactly (case-sensitive), and the repo must be public. If npm ci fails on peer conflicts, see How to Fix npm ERESOLVE Errors. For 2FA and trusted publishing setup, see npm Security Best Practices.
Conclusion
npm’s ignore rules do not fail with an error. Adding one file quietly changes which files get published. Pick either files or .npmignore and delete the other, keep ignore files out of subdirectories, and put npm pack --dry-run with a secrets grep in front of every npm publish in CI.
FAQs
What is the difference between npm pack --dry-run and npm publish --dry-run?
Both report which files would go into the tarball without uploading anything, so either one catches ignore-file mistakes. npm pack --dry-run is the simpler check for file contents. npm publish --dry-run rehearses the publish command itself. In current npm versions it also asks the registry whether that version already exists and fails if it does, but a missing login only produces a warning. A passing run does not prove the upload will succeed, because your login and 2FA are only really tested at upload time.
How do I check which files are inside a version that is already published?
Run npm pack with a registry spec, for example npm pack my-lib@1.4.2. npm fetches the published tarball, saves it to the current directory as my-lib-1.4.2.tgz and prints its filenames. List the contents with tar -tzf my-lib-1.4.2.tgz. To get the tarball URL for use with curl or another tool, run npm view my-lib@1.4.2 dist.tarball.
Does npm deprecate stop people from downloading a version with a leaked file?
No. npm deprecate adds a warning message to the registry entry, and npm shows it to anyone who installs that version, but the tarball stays downloadable. Anyone who pins the exact version still gets the leaked file. Only unpublishing removes the version from the registry, and that is limited by npm's unpublish policy. To remove a deprecation later, run npm deprecate again with an empty string as the message.
Can the files field in package.json use glob patterns?
Yes. You can list plain file names, folders, or glob patterns such as * and **/*, and a folder entry pulls in everything inside it. The syntax is .gitignore turned around: a matching pattern adds a file to the tarball instead of keeping it out. npm's always-included files, such as package.json, the README, the LICENSE and the main file, ship whether or not they appear in the array.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k