Anatomy of an npm supply-chain attack, and the OIDC trust that broke
In the first week of June 2026, someone published malicious versions of 32 packages under the @redhat-cloud-services npm namespace. Ninety-six versions in total, across packages pulled down roughly 117,000 times a week. The payload hunted for CI/CD secrets, cloud credentials for AWS, Azure, Google Cloud, and Kubernetes, SSH keys, and npm tokens.
Here is the part worth understanding: the attackers did not phish a maintainer or crack a password. They stole a trust relationship. The pipeline that was supposed to publish these packages was convinced to publish theirs. If you have ever set up a "publish on tag" workflow, the mechanism that failed is one you have probably used.
The one idea: a token you never see
Publishing to a registry needs a credential. The old way was a long-lived npm token sitting in a CI secret. The modern, "more secure" way is OIDC: OpenID Connect. The pipeline doesn't store a publishing token at all. Instead, when a job runs, the CI provider (GitHub Actions) mints a short-lived, signed JSON token that describes the job ("this is repo X, workflow Y, on branch main"), and the registry trades that description for a real, minutes-long publishing token.
The trust is: the registry believes the CI provider's signed description of who is asking. No secret is stored. That is genuinely better, right up until the description can be forged or the job that earns it can be hijacked.
A simplified OIDC token's claims look like this:
{
"iss": "https://token.actions.githubusercontent.com",
"sub": "repo:redhat-cloud-services/lib:ref:refs/heads/main",
"repository": "redhat-cloud-services/lib",
"workflow": "publish",
"exp": 1749000000
}
The registry's rule is essentially: if iss is GitHub and sub matches the repo I trust, hand over a publish token. So the attacker's goal is not to steal a token. It is to make a trusted job run their code, so the job earns the token for them.
Step one: get code into the trusted job
CI runs whatever the workflow file says, and workflow files run whatever the build scripts say. The classic openings:
- A compromised dependency of the build itself. If the
publishworkflow runsnpm installand any dev-dependency in that tree is malicious, attacker code runs inside the trusted job before the publish step. - A
pull_request_targetworkflow that checks out untrusted PR code but runs with the base repo's permissions. A contributor's PR becomes code running with the maintainer's trust. - A leaked or over-scoped GitHub Actions token that can edit workflows.
Whichever door, the result is the same: attacker-controlled code executes in a job whose OIDC identity the registry trusts.
Step two: turn the job's identity into a publish
Once code runs in that job, it asks GitHub for the OIDC token (a normal, documented request a job can make), exchanges it at the registry for a publish token, and pushes a new version. From the registry's side, everything is valid. The signature checks out. The repo matches. It is, by every automated measure, a legitimate release.
This is why it is so dangerous: there is no stolen-password alarm to trip. The pipeline did exactly what it was designed to do, for the wrong person.
Step three: the payload rides postinstall
npm packages can declare lifecycle scripts that run automatically when the package is installed. A postinstall hook runs on every machine that installs the package, including developer laptops and other CI systems:
{
"name": "innocent-looking-lib",
"version": "9.9.9",
"scripts": {
"postinstall": "node ./collect.js"
}
}
That is the amplification step. The malicious version doesn't just sit in the registry; it executes on every downstream npm install. The reported payload swept the environment for exactly the things CI machines are full of: AWS_* variables, GITHUB_TOKEN, NPM_TOKEN, kubeconfigs, SSH keys. On a build agent, the environment is the keys to production.
We are deliberately not printing an exfiltration script. The shape is the boring part anyway: read environment variables and well-known credential file paths, POST them somewhere. The interesting part is that postinstall gave it a foothold on thousands of machines that merely depended on a compromised package.
Why "supply chain" is the right name
You did not install the attacker's code. You installed a package you trusted, which trusted a pipeline, which trusted a CI identity, which trusted a job, which ran a dependency that was compromised. Each link was reasonable. The attack walked the chain backward from the thing everyone installs to the weakest link that could publish it.
That is the mental model to keep: your dependency tree is a trust tree, and you inherit the security of its weakest publisher. A package is only as safe as the CI pipeline that builds it and every dev-dependency that pipeline installs.
What this changes about "more secure by default"
OIDC really is an improvement over long-lived tokens, because there is no static secret to leak. But it moves the prize. The attacker no longer hunts for a token in a .env; they hunt for a way to run inside the job that earns the token. Short-lived credentials shrink the blast radius in time, not in trust. If the job is compromised, a 10-minute token is plenty.
None of this means "go back to static tokens." It means the threat moved up a level, to the integrity of the build itself: the workflow triggers, the dev-dependencies, the action versions. That is exactly where the defenses live, and it is its own post: lockfiles, integrity hashes you can verify yourself, scoped tokens, and pinning. If you want the build side of security, that thread runs through the cybersecurity with Python track on IWTLP, where the point is always to build the thing so you can see where it breaks.