Engineering 10 min read

How a Clean Next.js Repo Ran Malware on Every npm run dev

Papan Sarkar
Papan Sarkar

A codebase I got through Fiverr looked like an ordinary Next.js invoice app. It wasn’t. Two dependencies deep, gated behind an npm auth token shipped in the project’s own .npmrc, sat a private package carrying roughly 180 KB of heavily obfuscated JavaScript — wired to execute every time the dev server starts.

Attacks shaped like this are not rare and not aimed at companies. Socket’s researchers counted more than 338 malicious npm packages with over 50,000 cumulative downloads in a single wave, tied to more than 180 fake maintainer personas (Socket, October 2025). Microsoft has tracked the campaign it calls Contagious Interview as “active since at least December 2022” (Microsoft Security Blog). The target in both write-ups is the same: developers, on their own machines.

I caught this one before running it. Here is how it was built, how I found it, and the audit I now run on every codebase I didn’t write.

TL;DR

  • The trigger lived in tailwind.config.js, not in package.json scripts. A Tailwind plugin is loaded on every build, so npm install --ignore-scripts would not have protected me.
  • The visible plugin (tailwind-animationlab) was clean. The payload was in its dependency, @jasperquinn/postcss-motion-helpers — a private package, installable only with the token committed in the project’s .npmrc.
  • A delivery that ships an .npmrc containing an _authToken is a red flag on its own. Nobody hands you their npm credentials by accident.
  • grep across the project found nothing, because the malicious code was never in the project.
  • Read untrusted code in a disposable environment with no real credentials. I used a burner GitHub account and a Codespace.

The delivery

The project was called PromptToInvoice: a Next.js frontend with React, Tailwind 3 and lucide-react for icons. Small SaaS frontend, nothing unusual in the file tree.

Before running anything, I did two things. First I checked my own Mac for persistence — LaunchAgents, LaunchDaemons, login items — to confirm nothing had already executed. Nothing had. Then I moved the code somewhere it could do no damage, and from that point on I only read files.

Step 1: Read it in a sandbox, not on your laptop

I created a new, empty GitHub account and started a blank Codespace from it. The account matters more than the VM. A codespace inherits credentials belonging to whoever created it, so if something does execute, it should find nothing worth stealing.

Cost is not a reason to skip this. GitHub Free for personal accounts includes 120 hours of Codespaces compute and 15 GB-month of storage per month, and with no payment method on file, “usage is blocked once you use up your quota” (GitHub Codespaces billing). There is no way to accidentally run up a bill.

Microsoft’s guidance for this exact attack class is the same shape: use “a dedicated, isolated environment for coding tests and take-home assignments,” a non-persistent virtual machine rather than a workstation with access to production credentials, internal repositories or privileged cloud sessions (Microsoft Security Blog).

I zipped the project without node_modules or .git, dragged it into the codespace, and unzipped it there.

Step 2: Every standard check came back clean

This is the part that worries me, because it is where most developers would stop.

package.json scripts held only dev, build, start and lint, all calling next or eslint. No preinstall, no postinstall, no prepare.

Dangerous-call grep came back empty:

grep -rnE "eval\(|new Function|child_process|execSync|spawn\(|atob\(|fromCharCode|base64" \
  --include=*.js --include=*.mjs --include=*.cjs --include=*.ts --include=*.jsx --include=*.tsx . \
  | grep -v node_modules

Long-line grep, which is how whitespace-padded payloads usually show up, matched only SVG path data and long Tailwind class strings. I cleared both as harmless. Worth noting that this is not always a safe call: Elastic Security Labs documented a variant of the same campaign hiding payload fragments as Base64 inside the comments of SVG country-flag images, reassembled and executed by a script (Elastic Security Labs, July 2026).

By the usual checks, the repo was clean. The repo was clean. The malicious code simply wasn’t in it.

Step 3: Three things that did not add up

1. The project shipped an npm auth token

$ cat .npmrc
//registry.npmjs.org/:_authToken=npm_HIswo…[redacted]

That line tells npm to authenticate to the public registry as a specific account. An npm access token is an alternative to a username and password and carries “the right to install and/or publish your modules” (npm docs) — including read access to private packages that account can see. A committed npm token is a recognised leaked-credential class; GitHub’s secret scanning has a dedicated npm_access_token pattern for it (GitHub docs).

A stranger’s deliverable has no reason to include one. Its only function here is to let npm install fetch something you otherwise could not.

2. A Tailwind plugin I had never heard of

devDependencies included "tailwind-animationlab": "^2.0.6". The genuine Tailwind animation packages cluster around similar names — tailwindcss-animate, tailwindcss-animated, tailwind-animations. This one sounds like it belongs in that set, and that is the whole trick. Socket’s package list for this campaign includes names impersonating tailwindcss, vite, eslint and next for the same reason (Socket).

At the bottom of tailwind.config.js:

plugins: [
  require('tailwind-animationlab')({
    duration: '500ms',
    delay: '1s',
    iterationCount: '2'
  })
],

Nothing about that looks wrong, and that is the problem. Tailwind’s own documentation registers plugins exactly this way, with require() calls inside the plugins array (Tailwind CSS v3 configuration). The config file was also padded with long, friendly comments explaining why various unused theme values existed. It read like carefully maintained code, which made one more require easy to skim past.

3. The timestamps did not match

Nearly every file was dated Sep 13, 13:29. Three were not:

FileModified
.npmrcSep 13, 14:15
tailwind.config.jsSep 28, 09:49
package.jsonSep 28, 09:50

Those are precisely the three files needed to authorize, inject and execute a dependency. They were edited separately from the app, and two of them within a minute of each other.

Step 4: Following the dependency chain

Run from the home directory, so the project’s token was not picked up:

cd ~ && npm view tailwind-animationlab

The package was real. It was also wrong in four ways:

  • One version, numbered 2.0.6. New packages do not start at 2.0.6. The version was chosen to look mature and to satisfy the ^2.0.6 already sitting in the project.
  • Published the same day the config was edited. The registry gives its creation time as 2026-09-28. The tailwind.config.js and package.json edits are dated Sep 28 too. The dependency was manufactured for this delivery.
  • The maintainer did not match the author. npm maintainer adriannamrr; author, homepage and repository all pointing at sericpieap.
  • One dependency I did not recognise: @jasperquinn/postcss-motion-helpers, alongside four real ones (animate.css, postcss, postcss-js, postcss-selector-parser).

I unpacked the tarball without executing it:

npm pack tailwind-animationlab@2.0.6 --ignore-scripts
tar -xzf tailwind-animationlab-2.0.6.tgz
grep -rnE "eval|Function\(|child_process|https?://|atob|base64|fromCharCode|homedir" package/

Clean, apart from its own homepage and repo URLs. The decoy was built to survive a code review — because that is the layer a careful developer actually inspects.

Then the scoped dependency:

npm view @jasperquinn/postcss-motion-helpers
# npm error 404 Not Found

404 on the public registry. Private npm packages “always have a scope,” and a user-scoped private package “can only be accessed by you and collaborators to whom you have granted read or read/write access” (npm docs). So a public package depended on a private one. For anyone else on earth, installing tailwind-animationlab fails. It resolves only on a machine holding the attacker’s token — which is to say, on a victim’s machine, supplied through that .npmrc.

With the token, inside the burner codespace, still executing nothing:

NPM_CONFIG_USERCONFIG=./PromptToInvoice/frontend/.npmrc \
  npm pack @jasperquinn/postcss-motion-helpers --ignore-scripts

The tarball’s src/config.js was a wall of machine-generated obfuscation: an encoded blob, _0x hex identifiers, a large switch-based dispatch loop, heavy Proxy and Reflect use. That is the signature of commercial obfuscation with code virtualization. A PostCSS helper has no reason to look like that.

I did not deobfuscate it and I would not recommend trying. A private, obfuscated package that the project is rigged to execute is a conclusion, not a lead.

Why --ignore-scripts would not have saved me

Most npm hardening advice centres on install hooks, and --ignore-scripts does mean “npm does not run scripts specified in package.json files.” But npm’s own config reference is explicit that commands like npm run and npm start “will still run their intended script if ignore-scripts is set” — they only skip the pre- and post-scripts (npm config).

This attack never wanted an install hook. Tailwind v3 loads tailwind.config.js whenever it generates CSS, so the require() in plugins evaluates on every npm run dev and every npm run build. The trigger was the developer doing their job.

The same logic covers a neighbouring trick worth knowing. Microsoft’s write-up describes recent intrusions where simply opening the project in Visual Studio Code and trusting the author runs a task file that fetches the backdoor (Microsoft Security Blog). VS Code documents that property plainly: runOn: folderOpen means “the task will be run when the containing folder is opened,” and automatic tasks “never run in an untrusted workspace” (VS Code tasks). Clicking Trust on a stranger’s folder is a code-execution decision.

Does this match a known campaign?

The tradecraft lines up with Contagious Interview. Socket attributes the npm wave to “North Korean state-sponsored actors” and traces the packages to the BeaverTail and InvisibleFerret malware families (Socket); Elastic’s report on a related campaign is titled “New North Korean campaign uses fake coding interviews to steal developer credentials” and describes a tampered Next.js e-commerce template carrying a browser-credential and crypto-wallet stealer plus a Socket.IO remote access trojan (Elastic). Microsoft, for its part, describes the operation as “a sophisticated social engineering operation” without naming a state actor (Microsoft).

Being precise about what I know: I have not confirmed this sample is Contagious Interview. My delivery channel was a paid Fiverr gig, not a fake job interview, and I did not reverse the payload. What matches is the playbook — a plausible decoy package, mismatched maintainer personas, a staged private dependency, Next.js as the vehicle. If you have seen these package names in a dataset, I would like to hear about it.

The audit I now run on code I did not write

CheckWhere to lookRed flag
Hidden config filesls -la.npmrc with _authToken, a custom registry=, .vscode/tasks.json
Install hooksscripts in every package.jsonpreinstall/postinstall/prepare running node x.js or curl
Editor auto-run.vscode/tasks.json"runOn": "folderOpen"
Config files that execute*.config.js — Tailwind, PostCSS, Next, ESLint, Viterequire() of anything unfamiliar; long whitespace-padded lines
Dependency namespackage.jsonNear-miss spellings of popular packages
Dependency provenancenpm view <pkg>, run from ~Single version, starting at 2.x+, published recently, maintainer ≠ author
Transitive depsnpm view <pkg> dependenciesUnknown scoped packages; a 404 on the public registry
Non-registry sourcesgrep -E '"(github:|git\+|https?://|file:)' package.jsonDependencies pulled from Git URLs or tarballs
File timestampsls -laA handful of config files edited separately from everything else

Two rules carry most of the weight: read unknown code in a disposable environment with no real credentials, and treat any .npmrc you did not write as hostile.

What I reported, and what is still live

I reported both packages and both maintainer accounts through npm’s malware process (npm docs), including the fact that a delivered project shipped a token to unlock the private dependency — revoking that token breaks the delivery path for every copy of the project in circulation. I also reported the seller to Fiverr and the sericpieap GitHub account to GitHub, then deleted the codespace and the burner account. My Mac never executed any of it.

Checking again as I publish this: the sericpieap GitHub account and the sericpieap.github.io homepage both return 404, so that identity is gone. tailwind-animationlab@2.0.6 is still installable from the public registry, and @jasperquinn/postcss-motion-helpers still 404s publicly — the delivery mechanism is intact. I will update this post if that changes.

Indicators:

  • tailwind-animationlab@2.0.6 — npm maintainer adriannamrr, author/homepage sericpieap, shasum 9636e080056e59024e3e0d70f998d8f80e9129a9, registry creation date 2026-09-28
  • @jasperquinn/postcss-motion-helpers@1.0.3 — private, obfuscated src/config.js, referenced as ^1.0.1 by the decoy

If you hire outside developers

The lesson is not “don’t use freelancers.” I am one, and I have written before about how this market actually works from the seller’s side. The lesson is that a codebase is executable trust. Whoever hands it to you can run code on your machine the moment you start the dev server — and no amount of reading the application code catches a payload that lives two dependencies away.

If you are buying a Next.js or Node.js build, ask for a Git repository with readable history, no committed .npmrc, and a dependency list you can check on npm yourself. Then have someone review it before it touches a laptop or your CI.

If you were handed a codebase and want it audited before you run it — or you would rather have it built cleanly from the start — I ship React/Next.js and Node.js products for clients in Texas, Singapore, Lisbon and Delhi. Get in touch or book a 15-minute call. If you are on the other side of this and writing the code, my notes on what breaks in Next.js caching are the more cheerful read.

Sources

  1. Contagious Interview: Malware delivered through fake developer job interviews — Microsoft Security Blog
  2. North Korea’s Contagious Interview Campaign Escalates: 338 Malicious npm Packages, 50,000 Downloads — Socket
  3. New North Korean campaign uses fake coding interviews to steal developer credentials — Elastic Security Labs
  4. About access tokens — npm Docs
  5. About private packages — npm Docs
  6. Reporting malware in an npm package — npm Docs
  7. npm config reference (ignore-scripts) — npm Docs
  8. Configuration — Tailwind CSS v3
  9. Tasks in Visual Studio Code
  10. Supported secret scanning patterns — GitHub Docs
  11. About billing for GitHub Codespaces — GitHub Docs