0x010x050x1dUP = user present, UV = user verified, BE = backup eligible, BS = backed up. Values from sign-in assertions on Chromium virtual authenticators.
Multi-factor authentication (MFA) is a login check that requires two or more independent factors, so a stolen password alone cannot open an account.
Also known as 2FA, Two-factor authentication, Multifactor authentication
Multi-factor authentication (MFA, or multifactor authentication; 2FA when exactly two factors are used) is a login check that requires proof from at least two independent categories: something you know, something you have, or something you are. A stolen password alone then fails. MFA resists phishing only when the second factor is cryptographically bound to the real site.
MFA works. A 2023 Microsoft study of Azure Active Directory accounts found that MFA cut the risk of compromise by 99.22%, and by 98.56% for accounts whose passwords had already leaked. The most important fact about MFA is what that number measures: attacks on a login. MFA protects the sign-in ceremony, not the session cookie, API token, or CI credential the ceremony produces. In the open source supply chain, that distinction decides most incidents. The worms that tore through npm in 2025 and 2026 republished packages with stolen tokens that never faced a second factor.
Our position: every account that can publish a package should use phishing-resistant MFA, meaning a WebAuthn security key or passkey, with no phishable fallback left enabled. That is necessary and not sufficient. Registries should also require a fresh MFA check on each publish, not just each login, and npm's staged publishing should become the default instead of an opt-in. The people installing packages still cannot see whether that check happened for most releases, which is why they have to judge each release by its contents.
Multi-factor authentication (MFA) is authentication that requires factors from two or more different categories. Authentication itself is the step where a verifier, such as a website or a package registry, checks a claim that you are a particular account holder. Two-factor authentication (2FA) is MFA with exactly two factors, which is what most services deploy: a password plus one more proof. The three classic categories are knowledge (a password or PIN), possession (a phone, a security key, a private key on a device), and inherence (a fingerprint or face). NIST SP 800-63B-4, the US federal authentication standard finalized in July 2025, puts the requirement plainly: "A multi-factor authenticator requires two factors to execute a single authentication event."
The categories matter because MFA's value comes from independence. An attacker who phishes a password should still lack the phone. A password plus a security question is two knowledge factors, which is still single-factor authentication, because one keylogger or one phishing page captures both.
Independence is also where MFA quietly fails in practice. A time-based one-time password (TOTP) seed is a possession factor only while it lives on a separate device. Store it in the same browser that holds the password and the two factors share one point of failure. Our Threat Research Team found exactly that failure in the CL Suite Chrome extension, which offered a built-in 2FA code generator for Meta Business accounts and sent each user's TOTP seed to its operator. With the seed, the operator can generate valid codes indefinitely, until the victim re-enrolls with a new secret. That is why we treat browser extension security as part of account security.
MFA works by making the verifier demand a second proof that a password thief does not hold. The methods differ in one property that decides almost everything: whether the proof is tied to the website asking for it.
SMS and voice codes. The site sends a one-time code over the phone network, and the user types it back. An attacker who ports the victim's number to a new SIM receives the code instead. SP 800-63B-4 classifies phone-network delivery as "restricted": verifiers must offer an alternative and should check for SIM changes and number porting before trusting it.
TOTP authenticator apps. The app and the server share a secret seed, and each code is an HMAC of that seed and the current time window, 30 seconds by default, as defined in RFC 6238. Nothing in the code identifies the site where it is typed. A proxy phishing page can relay a fresh code to the real site inside its validity window, which is how the 2025 registry phishing campaigns defeated it.
Push approvals. The service sends a prompt to a phone app and the user taps Approve. The prompt proves the phone is present, not that the user started the login. In September 2022, an attacker with a contractor's purchased password sent repeated Uber login prompts. Uber's security update described the result: "Eventually, however, the contractor accepted one, and the attacker successfully logged in."
WebAuthn and FIDO security keys. The FIDO (Fast IDentity Online) Alliance defines the hardware side of this method. The authenticator generates a key pair per site and signs a challenge with the private key. The browser, not the page, records the requesting origin in the signed data, so a signature produced on a lookalike domain is worthless at the real one. FIDO Universal 2nd Factor (U2F) was the alliance's original standard for keys used only as a second factor. FIDO2 succeeded it, combining the W3C's WebAuthn API, a Recommendation since March 4, 2019, with the Client-to-Authenticator Protocol (CTAP). Our phishing entry walks through a browser-level test of that origin binding.
Field data shows the gap between these methods. In a 2019 Google study, an SMS code to a recovery phone blocked 76% of targeted attacks and on-device prompts blocked 90%. Zero users who relied exclusively on security keys fell to targeted phishing during the study. Side by side, the four methods compare like this:
| Method | What the attacker needs | Phishing-resistant? | Status in NIST SP 800-63B-4 |
|---|---|---|---|
| SMS or voice code | The code, by relay or SIM swap | No | Restricted |
| TOTP app | The code within its window, or the seed | No | Allowed, not phishing-resistant |
| Push approval | One accepted prompt | No | Allowed, not phishing-resistant |
| WebAuthn security key or passkey | The physical key or device, plus its PIN or biometric if required | Yes, by verifier name binding | Required option at AAL2, mandatory at AAL3 |
NIST's standard is blunt about the first three rows: "OTP authentication is not phishing-resistant," and the same is said of out-of-band methods such as push and SMS. At its middle level, Authentication Assurance Level 2 (AAL2), SP 800-63B-4 requires verifiers to offer at least one phishing-resistant option. At AAL3 it requires one. Our verdict: use a WebAuthn credential everywhere it is offered, and remove the TOTP and SMS fallbacks once it is enrolled. A fallback that can be phished sets the real security level of the account.
A passkey is multi-factor authentication when the site requires user verification. The private key on the device is the possession factor. The PIN, fingerprint, or face check that unlocks it is the knowledge or inherence factor. One gesture supplies both.
The evidence sits in one byte. Every WebAuthn signature covers an authenticatorData structure, and byte 32 of it is a flags field. Bit 0 is User Present (UP): someone touched the authenticator. Bit 2 is User Verified (UV): the authenticator checked a PIN or biometric locally. Bits 3 and 4, Backup Eligible (BE) and Backup State (BS), say whether the key can sync to other devices and whether it has. Because the flags are inside the signed data, a server can enforce them, not just read them.
We ran three virtual authenticators through Chromium's DevTools WebAuthn domain, driven by Playwright 1.63.0. For each, we registered a credential on localhost, signed in, and read the flags byte out of the assertion:
const assertion = await navigator.credentials.get({ publicKey: {
challenge, rpId: 'localhost', userVerification: 'required',
allowCredentials: [{ type: 'public-key', id: credentialId }] } })
const authData = new Uint8Array(assertion.response.authenticatorData)
const flags = authData[32] // bytes 0-31 are SHA-256(rpId)
// UP = flags & 0x01, UV = flags & 0x04, BE = flags & 0x08, BS = flags & 0x10U2F-style key, no PIN flags=0x01 00000001 UP=1 UV=0 BE=0 BS=0
FIDO2 key, PIN verified flags=0x05 00000101 UP=1 UV=1 BE=0 BS=0
Synced passkey, UV verified flags=0x1d 00011101 UP=1 UV=1 BE=1 BS=10x010x050x1dUP = user present, UV = user verified, BE = backup eligible, BS = backed up. Values from sign-in assertions on Chromium virtual authenticators.
Figure 2 lays out the same three flags bytes bit by bit, from bit 7 (ED, extension data) down to bit 0 (UP). The U2F-style key, which has no PIN, sets only UP, for 0x01. The FIDO2 key with a verified PIN sets UP and UV, for 0x05. The synced passkey sets UP, UV, BE, and BS, for 0x1d.
The first credential proves only possession. It is a second factor, which is why U2F keys were designed to follow a password. The second and third prove possession and verification in one signature, so they can replace the password entirely. The third also reports that its private key is backed up to a sync provider. SP 800-63B-4 permits exportable keys like that one at AAL2 and states that "syncable authenticators SHALL NOT be used at AAL3." A synced passkey is as safe as the cloud account it syncs through.
The practical rule for anyone running a login: request userVerification: "required" and reject assertions whose UV bit is 0 when the passkey is the only factor. A site that skips the check has built single-factor authentication with a very good possession factor.
Multi-factor authentication is important because stolen passwords are the cheapest way into an account, and MFA makes them insufficient on their own. Credential stuffing, password reuse, and infostealer logs supply passwords at scale. A second factor turns each of those from an account takeover into a failed login.
The cost of skipping MFA is concrete. Attackers entered Change Healthcare's network in February 2024 through a remote access portal using compromised credentials. At the May 1, 2024 House hearing, the committee chair stated that "this portal did not have multi-factor authentication enabled." The ransomware attack disrupted claims processing for a company that processes an estimated half of all US medical claims, as we covered in our report on the Change Healthcare breach.
Open source has its own version of that story. On July 12, 2018, an ESLint maintainer who, in the project's postmortem, "had reused their npm password on several other sites and did not have two-factor authentication enabled" had their account taken over. The attacker published eslint-scope@3.7.2, whose postinstall script sent the contents of each installer's .npmrc, including npm tokens, to the attacker. It was live for about two hours, and npm responded by revoking every access token issued before 12:30 UTC that day. One missing second factor on one account became a credential harvester on every machine that installed the package. That pattern, a single login reaching every downstream install, is what makes maintainer account takeover different from ordinary account takeover.
Most major package registries now require MFA for at least some publishers. They got there in stages, starting with the most-depended-on packages. As of November 2026:
| Registry | Requirement | Since | Does the upload itself face a second factor? |
|---|---|---|---|
| GitHub | All code contributors | Rollout began March 13, 2023 | Not applicable (git push uses SSH keys or tokens) |
| npm | Top 100 packages first, then wider cohorts; now every interactive publish | February 1, 2022 (top 100); December 9, 2025 (interactive publishing) | Interactive publish: yes. Bypass-2FA token or trusted publishing: no. Staged publish: yes, at approval |
| PyPI | All accounts | January 1, 2024 | No. Uploads require an API token or a trusted publisher |
| RubyGems | Owners of gems with over 180 million downloads | August 15, 2022 | Only at the "UI and API" MFA level, which requires a code on gem push; the "UI and gem signin" level does not |
| NuGet | All accounts, via the linked Microsoft identity | March 8, 2022 (new accounts) | No. Pushes use API keys |
| crates.io | None of its own; login requires a GitHub account | Not applicable | No. Publishing uses API tokens or trusted publishing |
Sources: GitHub, npm and npm sessions, PyPI, RubyGems, NuGet, crates.io.
The registry mandates worked on their own terms. GitHub reported an opt-in rate of nearly 95% among contributors it required to enroll in 2023. The last column is the problem. On most registries, the account is protected by MFA and the upload is protected by a token. PyPI's December 2023 enforcement notice quotes the error that uploaders now see: "an API Token or Trusted Publisher must be used to upload." Our verdict: an MFA mandate tells you how hard it is to log in as a maintainer. It tells you little about how hard it is to publish as one.
MFA stops at the first request that is non-interactive. A second factor needs a human at a keyboard, and automated releases have none, so every registry offers a credential that skips the check. That credential is the target.
npm's protocol shows the boundary precisely. When an account requires MFA for writes, the registry answers a publish with HTTP 401 and a WWW-Authenticate: OTP header. The client prompts, then retries the same request with the code in an npm-otp header. This is the check, lightly reformatted, in npm-registry-fetch 20.0.1:
if (res.status === 401 && res.headers.get('www-authenticate')) {
const auth = res.headers.get('www-authenticate')
.split(/,\s*/)
.map(s => s.toLowerCase())
if (auth.indexOf('ipaddress') !== -1) {
throw new errors.HttpErrorAuthIPAddress(method, res, parsed, opts.spec)
} else if (auth.indexOf('otp') !== -1) {
throw new errors.HttpErrorAuthOTP(method, res, parsed, opts.spec) // code: EOTP
}Security keys ride the same channel. When the 401 body carries authUrl and doneUrl, npm CLI 12.2.1 opens the browser for the WebAuthn ceremony, polls doneUrl until the registry returns a token, and sends that token as the npm-otp value. Before any of that, the retry wrapper checks for a terminal:
const otplease = async (npm, opts, fn) => {
try {
return await fn(opts)
} catch (err) {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
throw err
}
// ...then the web OTP flow, or a typed code, and a retry with { otp }In CI there is no TTY, so an MFA-gated publish simply fails. Maintainers therefore publish from CI in one of two ways. The first is a granular API token with the bypass-2FA option set. Under npm's bypass-2FA token deprecation, those tokens lost the ability to change account and package settings on July 31, 2026. As of November 2026 they can still publish directly; npm targets January 2027 for removing that. The second is trusted publishing, where the CI provider's OpenID Connect (OIDC) token is exchanged for a publish credential. Neither involves a second factor. PyPI uploads work the same way, with an API token or a trusted publisher.
npm loginnpm-otpnpm stage publishapprover recordedAs of November 2026. Sources: npm docs, GitHub changelog.
Figure 1 in text. The four npm publish paths, as of November 2026:
| Path | Credential presented | MFA check before the version is public? |
|---|---|---|
| Interactive publish from a terminal | Session from npm login, plus a security-key or TOTP response in the npm-otp header | Yes |
| CI with a bypass-2FA granular token | Long-lived token stored as a CI secret | No; npm plans to remove direct publishing for these tokens around January 2027 |
| CI with trusted publishing | Short-lived OIDC token minted by the workflow | No |
| CI with staged publishing | OIDC or stage-only token to stage; maintainer session to approve | Yes, at approval; OIDC tokens cannot approve |
Attackers took the paths without a check. The Shai-Hulud worms, beginning with the September 2025 tinycolor compromise, harvest npm credentials from developer machines and CI runners and use stolen tokens to republish every package the victim maintains. Late on May 18, 2026, the @antv compromise used the hijacked atool account to push 639 malicious versions across 323 packages in a single automated burst. npm's response, on May 19, was to invalidate every write-capable granular token that bypassed 2FA. Maintainers minted new ones, and the worm went back to harvesting them.
Sessions are the other gap. A browser session cookie is a bearer credential issued after MFA succeeds, and an infostealer that copies it needs no second factor. In the LiteLLM compromise, attackers ran secret-exfiltration workflows in the project's GitHub organization eleven days before publishing malicious litellm==1.107.0 wheels on October 21, 2026. One stolen secret was a Playwright storage state, which, as we noted in our timeline of the attack, "permits authenticated access to the associated Linear account without a password and without satisfying multi-factor authentication." LiteLLM traced initial access to a leaked platform key that yielded an organization owner's GitHub token. Every step in that chain ran on tokens and stored sessions, and none of them asks for a second factor.
The hardest case is a compromised maintainer machine. In the axios compromise of March 31, 2026, the lead maintainer had installed remote access malware during a fake meeting set up by the attackers, who then used his live sessions to publish. MFA authenticates a person to a server. It cannot tell whether malware is driving that person's browser.
npm's staged publishing is the first registry control that puts an MFA check on the release itself. A workflow runs npm stage publish, and the version stays private until a maintainer runs npm stage approve or clicks Approve on npmjs.com. Per npm's documentation, "To publish a staged package to the registry, approve it with 2FA." Since September 18, 2026, maintainers can also mint stage-only tokens, so a token stolen from CI can stage a version but not release it.
Staged approval leaves a record that consumers can read. The approving maintainer appears as an approver object inside _npmUser in the version's registry metadata, a field that pnpm 11.5 now treats as its strongest trust signal. Here is axios@1.21.0, fetched from the registry on November 4, 2026, with the email addresses removed:
$ curl -s https://registry.npmjs.org/axios | jq '.versions["1.21.0"]._npmUser'
{
"name": "GitHub Actions",
"email": "<redacted>",
"approver": {
"name": "jasonsaayman",
"email": "<redacted>"
},
"trustedPublisher": {
"id": "github",
"oidcConfigId": "9acc5c94-36dd-4b4c-ba88-84d63a9999fc"
}
}The registry's history shows the switch. Every axios release from 1.17.0 on June 3, 2026 onward carries an approver. The five releases before it, published between April 19 and May 13, came through trusted publishing with provenance and no approver.
We pulled the latest version of 106 widely used npm packages on November 4, 2026 (UTC) and sorted them by what their metadata records:
| What _npmUser records | Packages | What it proves about MFA |
|---|---|---|
| An approver (staged publish) | 17 | A maintainer passed an MFA check to release this version |
| A trustedPublisher, no approver | 44 | The version came from a configured CI workflow; no MFA check |
| A user account only | 45 | Nothing; an interactive MFA publish and a bypass-2FA token look identical |
Approved releases included axios, express, zod, vitest, postcss, socket.io, keyv, and pnpm itself. Lodash, chalk, typescript, eslint, and @types/node had no approver on their latest version. The sample is our own list, not a ranking, so read the split as a snapshot rather than an ecosystem rate. The third row is the point: for 45 of 106 packages, a consumer cannot tell from the registry whether the latest release passed any second factor.
Staged publishing has real costs and limits. Every release waits for a person, which is friction a busy maintainer of hundreds of packages will feel. It is opt-in and configured per package. The approval is only as trustworthy as the approver's machine: an attacker with a remote access trojan can stage a version and wait for the maintainer to approve something routine. And package provenance and trusted publishing, which staged publishing builds on, prove which pipeline built a release, not whether its code is what the maintainer meant to ship.
MFA is a contract between a maintainer and a registry, and consumers are not party to it. A registry can require MFA at login and still accept a publish from a token that skipped it, and until staged publishing, nothing in the metadata said which happened. npm should make staged publishing the default for packages above a download threshold, and every registry should publish an explicit, signed record of how each version was authorized: interactive MFA, token, OIDC, or human approval. Tools should not have to infer that from a username field.
Even a perfect record would prove that a person approved a release, not what the release does. The @antv versions, the LiteLLM wheels, and axios@1.14.1 all came from accounts with real credentials. What caught them was reading the code: Socket flagged the LiteLLM wheels seven minutes after they were published. MFA decides who may publish. Only the contents of the package say what was published.
Manifest confusion is a mismatch between an npm package's registry manifest and the package.json in its tarball, two copies that tools trust inconsistently.
A software bill of materials (SBOM) is a machine-readable inventory of the components, versions, and dependency relationships inside a piece of software.
Software composition analysis (SCA) inventories the third-party code in an application and checks each component for vulnerabilities, licenses, and malware.