Back to glossary

Phishing

Phishing is an impersonation attack that tricks a person into entering credentials, approving a login, or running attacker code.

Also known as Phishing attack, Spear phishing, Spearphishing

Phishing is a social engineering attack in which an attacker impersonates a trusted party so a victim enters credentials, approves a login, or runs attacker code. Spear phishing targets one researched person. Phishing works because passwords and one-time codes prove who someone is, not which site they are on. One phished maintainer can ship malware to millions.

Phishing is the most reported cybercrime in the United States. The FBI's Internet Crime Complaint Center logged 191,561 phishing and spoofing complaints in 2025, more than any other category, according to its 2025 annual report. Verizon's 2026 Data Breach Investigations Report puts phishing at 16% of breach entry points. In the software supply chain the target is narrower and the payoff is larger: a package maintainer whose single login can publish code that millions of projects install automatically. Between July and September 2025, lookalike login pages took over maintainer accounts on npm and PyPI and pushed malware into chalk and debug, DuckDB, eslint-config-prettier, and num2words.

Our position: every account that can publish a package should sign in with an origin-bound credential, meaning a WebAuthn security key or passkey, with no weaker method left enabled as a fallback. That closes the relay attacks of 2025. It does nothing against the 2026 playbook, in which attackers talked the axios maintainer into installing a remote access trojan during a fake meeting and then rode his live sessions. Once attacker code runs on a maintainer's machine, the defense that remains belongs to everyone downstream: analyze what a new release contains before it installs.

What is a phishing attack?

#

A phishing attack is impersonation with a request attached. The attacker borrows the identity of someone the victim already trusts, such as a bank, an employer, a package registry, or a colleague, and asks for something that only makes sense coming from that party: sign in here, confirm this code, pay this invoice, install this update.

The request always serves one of four goals. The attacker wants a credential (a password, a one-time code, a session cookie, an API token), money, code execution on the victim's machine, or consent, such as an OAuth grant to a malicious app. The variants differ in channel and targeting, not in mechanism:

VariantChannelWho it targetsWhat the attacker wants
Bulk email phishingEmailAnyone, at volumePasswords, card numbers, malware installs
Spear phishingEmail, LinkedIn, Slack, GitHubOne researched personThat person's accounts or machine
WhalingEmail, phoneExecutivesWire approvals, privileged access
Business email compromise (BEC)Spoofed or hijacked business emailFinance and sales staffRedirected payments
Smishing and vishingSMS, voice callsAnyone with a phoneOne-time codes, remote-support installs
QuishingQR codesMobile usersCredentials typed on unmanaged phones
Adversary-in-the-middle (AiTM)Reverse-proxy copy of a real login pageAccounts protected by passwords plus codesLive session cookies and one-time codes
Consent phishingOAuth consent or device-code prompt on the real siteCloud and developer accountsA token granted to the attacker's app
Fake-meeting lureChat invite, then a spoofed video callDevelopers, founders, maintainersMalware on the victim's machine

Business email compromise is the most expensive variant. The FBI recorded $3.05 billion in BEC losses in 2025, second only to investment fraud. Spear phishing is the variant that matters most for open source, because a maintainer's name, email address, and package list are all public. Security teams measure their own exposure with authorized simulations; TrustedSec's open source Social-Engineer Toolkit (SET) is a long-standing framework for running them.

How does a phishing attack work?

#

A phishing attack works in four stages: reconnaissance, pretext, capture, and use. The 2025 attacks on npm maintainers show each stage clearly.

Reconnaissance. Attackers do not have to guess who maintains a popular package. The npm registry returns every maintainer's account email in public package metadata:

Bash
$ npm view debug maintainers
[ 'qix <[email redacted]>', 'tootallnate <[email redacted]>' ]

We redacted the addresses here; the registry response contains them in full. When Socket reported the eslint-config-prettier compromise in July 2025, we noted that threat actors scrape this metadata to build target lists. The crates.io phishing campaign two months later appeared to harvest addresses from Git commit metadata instead, because crates.io does not expose emails in its API.

Pretext. The lure imitates the registry's own security mail, because maintainers are trained to act on it. The npnjs.com phishing email asked recipients to log in to verify their account. The lure in the Qix compromise warned that outdated 2FA credentials would lock the account and offered an "Update 2FA Now" button. The crates.io lure claimed the registry itself had been breached. Each one came from a lookalike domain: npnjs.com and npmjs.help for npm, pypj.org for PyPI, rustfoundation.dev for crates.io. Swapping one character or the top-level domain is the same trick as typosquatting a package name, applied to a domain.

Capture. The victim lands on a page that looks exactly like the registry, because it is the registry, relayed through the attacker's server. The next section explains why that relay defeats one-time codes.

Use. The attacker converts the captured session into something durable, usually a publish token, and starts releasing versions. That step turns a phished login into a maintainer account takeover, and the versions it produces are indistinguishable from legitimate releases except by their contents.

Why do one-time codes fail against proxy phishing?

#

One-time codes fail against proxy phishing because a code is a bearer secret: it proves possession of a seed, and says nothing about where it is being typed. A time-based one-time password (TOTP), as defined in RFC 6238, is an HMAC of a shared secret and the current 30-second window. The whole algorithm fits in a few lines:

Python
import hmac, hashlib, struct, time

def totp(secret: bytes, now: float, step: int = 30, digits: int = 6) -> str:
    counter = int(now // step)                      # same value for 30 seconds
    mac = hmac.new(secret, struct.pack(">Q", counter), hashlib.sha1).digest()
    offset = mac[-1] & 0x0F                         # RFC 4226 dynamic truncation
    code = struct.unpack(">I", mac[offset:offset + 4])[0] & 0x7FFFFFFF
    return str(code % 10**digits).zfill(digits)

# RFC 6238 test vector: secret "12345678901234567890", T = 59 s
print(totp(b"12345678901234567890", 59))            # -> 287082

The code depends on exactly two things, the secret and the clock. The domain in the address bar is not one of them. A code typed into npmjs.help is as valid at npmjs.com as a code typed into npmjs.com, for as long as its 30-second window lasts. SMS codes and push approvals share the same property.

An adversary-in-the-middle (AiTM) phishing kit exploits it by reverse-proxying the real site. Every request the victim makes goes to the real registry, every response comes back with the attacker's domain swapped in, and the attacker keeps a copy of the session. DuckDB's maintainers described exactly this after their npm account fell to the same npmjs.help campaign as Qix. Their advisory says "the copycat website forwarded all actions to the actual npm website," so the maintainer's 2FA reset really happened on npm. The attackers then added a new API token inside that session and published with it.

PyPI saw the same design in July 2025. Its incident report says the pypj.org server would "transparently proxy requests to PyPI.org." Four accounts were phished, attackers generated two API tokens, and malicious num2words versions 0.5.15 and 0.5.16 were uploaded before PyPI removed them.

Password + TOTP codeThe proxy relays everything

Your browser Proxy npmjs.help Registry npmjs.com user + password user + password "enter 2FA code" "enter 2FA code" 492817 492817 ✓ session cookie cloned profile page create npm token

Result: the attacker holds a live session and a token of its own. The code was valid because it never encoded where it was typed.

WebAuthn security keyNothing the proxy can relay

Your browser Proxy npmjs.help Registry npmjs.com user + password user + password challenge,rpId npmjs.com challenge,rpId npmjs.com page origin is npmjs.help ✗ SecurityError retry: rpIdnpmjs.help no key for npmjs.help ✗ NotAllowedError

Result: no signature leaves the browser. The key is scoped to npmjs.com, and the browser knows it is on npmjs.help.

Figure 1. The same proxy against two second factors. A TOTP code is valid wherever it is typed. A WebAuthn signature is bound to the origin the browser is actually on.

Figure 1, in text. On the left, a maintainer signs in with a password and TOTP through a proxy at npmjs.help. The proxy forwards the username and password to npmjs.com, relays npm's request for a 2FA code back to the maintainer, forwards the code (492817 in the diagram) within its 30-second window, and receives a valid session cookie. The proxy then uses that session to create an npm token of its own. On the right, the same maintainer uses a WebAuthn security key. The proxy forwards npm's challenge, which names npmjs.com as the relying party. The browser sees it is running on npmjs.help, refuses to produce a signature for npmjs.com, and throws a SecurityError. If the proxy rewrites the request to name npmjs.help instead, the key holds no credential for npmjs.help and the browser throws NotAllowedError. No signature ever leaves the browser, so the proxy has nothing to relay.

How WebAuthn breaks the relay

#

WebAuthn breaks the relay by putting the website's origin inside the thing that gets signed. When a site asks for a login, the browser, not the page, writes a small JSON document called clientDataJSON that records the challenge and the origin the request came from. The authenticator signs a hash of that document together with a hash of the relying party ID (RP ID), the domain the credential was registered to. The WebAuthn specification states the guarantee directly: operations are "scoped to a particular origin" and cannot be replayed against a different one.

The scoping is enforced twice. The browser refuses to request a signature for an RP ID that is not the current domain or a registrable suffix of it, so npmjs.help cannot ask for an npmjs.com signature. The server then checks the signed origin, so a signature produced on npmjs.help would fail verification even if it could be obtained.

We tested this with Chromium's virtual authenticator. We registered a credential on registry.localhost, then tried to use it from the lookalike reglstry.localhost, which stands in for a phishing domain:

Plain text
real site   registry.localhost   rpId=registry.localhost
  OK {"type":"webauthn.get","challenge":"c2VydmVyLW5vbmNlLTEyMw","origin":"http://registry.localhost:8001","crossOrigin":false,"other_keys_can_be_added_here":"do not compare clientDataJSON against a template. See https://goo.gl/yabPex"}

lookalike   reglstry.localhost   rpId=registry.localhost
  SecurityError: The relying party ID is not a registrable domain suffix of, nor equal to the current domain. Subsequently, an attempt to fetch the .well-known/webauthn resource of the claimed RP ID failed.

lookalike   reglstry.localhost   rpId=reglstry.localhost
  NotAllowedError: The operation either timed out or was not allowed.

The first result is the clientDataJSON a real login produces; Chrome sometimes inserts the other_keys_can_be_added_here field on purpose so servers parse the JSON instead of matching it as a string. The second result is the proxy's problem: the browser will not sign for the real registry from the wrong domain. The third result is the proxy's workaround failing: there is no credential for the lookalike domain to use. This is why PyPI's report says a WebAuthn key would have stopped its July 2025 attack.

Origin-bound multi-factor authentication (MFA) has real limits, and each one has been exploited:

  • Fallback methods. A key protects nothing if the account still accepts a TOTP code or a recovery flow the attacker can phish. Under GitHub's npm security plan, npm stopped accepting new TOTP enrollments in October 2025, but existing TOTP setups kept working after that change, and a maintainer with both a key and TOTP can still be relayed through the TOTP path.
  • What happens after login. WebAuthn protects the sign-in ceremony. The session cookie and any tokens created afterward are ordinary bearer credentials. Malware that copies them from the browser needs no second factor.
  • Consent and device-code flows. An OAuth consent screen or device-code prompt is a real page on the real domain. Origin binding cannot tell a user approving a legitimate app from one approving a malicious one.
  • Malware on the machine. An attacker with code execution can wait for the maintainer to touch the key, or simply use the sessions that already exist.

When phishing skips the login page

#

The 2026 campaigns against JavaScript maintainers targeted the machine instead of the password. In the axios compromise, the project's post-mortem states the root cause plainly: "The attacker gained access to the lead maintainer's PC through a targeted social engineering campaign and RAT malware." The campaign ran for about two weeks before March 31, 2026. As we reported when the axios maintainer confirmed social engineering, it used a cloned company identity, a staged Slack workspace, and staged meetings before the malicious install. A remote access trojan (RAT) on the maintainer's machine let the attacker hijack his active npm and GitHub sessions, so neither 2FA nor OIDC publishing stood in the way.

Axios was one target among many. The campaign against high-impact Node.js maintainers reached maintainers of Fastify, Lodash, dotenv, Express, and Node.js core, as well as several Socket engineers. The pattern was consistent: a LinkedIn or Slack approach from a cloned startup persona, weeks of rapport, a video call on a spoofed Teams or Streamyard page, and then a technical "error" that could only be fixed by installing an app or pasting a terminal command. Security researcher Tay linked the playbook to the North Korea-nexus group that Mandiant tracks as UNC1069, and summarized the difference for maintainers: "This is also really not your typical phishing."

Other lures aimed at developers ran in the same weeks. The Linux Foundation Slack impersonation campaign in April 2026 used a Google Sites page to harvest an email address and verification code, then asked victims to install a malicious root certificate. The fake VS Code security alert campaign in March 2026 posted thousands of GitHub Discussions about fabricated vulnerabilities, so GitHub's own notification emails delivered the lure.

A maintainer phished into running malware still has to publish a release, and that release still has to be installed to do damage. Downstream defenses get a window. In the axios case it was a short one:

  1. mid-Mar~2 weeksSocial engineering of the lead maintainer begins: cloned company, staged Slack workspace, staged meetings, then a RAT install.
  2. 05:57Mar 30plain-crypto-js@4.2.0 published: a clean copy of crypto-js with no payload.
  3. 23:59Mar 30plain-crypto-js@4.2.1 published with a postinstall dropper.
  4. 00:05Mar 31Socket flags plain-crypto-js@4.2.1 as malware, 6.5 minutes after publish.
  5. 00:21Mar 31axios@1.14.1 published from the maintainer's account, adding plain-crypto-js as a dependency.
  6. 01:00Mar 31axios@0.30.4 poisons the legacy 0.x line.
  7. 03:15Mar 31Both axios versions removed from npm.
  8. 03:25Mar 31npm replaces plain-crypto-js with a security holding package.
Figure 2. The axios compromise in UTC. The phishing took two weeks. The malicious dependency was detectable before the poisoned axios release existed, and the releases were live for about three hours.
Time (UTC)EventSource
About two weeks before March 31, 2026Social engineering campaign against the lead maintainer begins, ending in a RAT installaxios post-mortem
March 30, 05:57:32plain-crypto-js@4.2.0 published, a clean copy of crypto-js with no payloadnpm registry time field
March 30, 23:59:12plain-crypto-js@4.2.1 published with a postinstall droppernpm registry time field
March 31, 00:05:41Socket flags plain-crypto-js@4.2.1 as malwareSocket axios analysis
March 31, 00:21:58axios@1.14.1 published from the maintainer's account, adding plain-crypto-js as a dependencynpm registry time field
March 31, 01:00:57axios@0.30.4 published on the legacy 0.x linenpm registry time field
March 31, 03:15Both malicious axios versions removed from npmaxios post-mortem
March 31, 03:25:11npm replaces plain-crypto-js with a security holding packagenpm registry time field

The registry still records the dependency's history, even though npm replaced the package with a security placeholder:

Bash
$ curl -s https://registry.npmjs.org/plain-crypto-js | jq '.time, .description'
{
  "created": "2026-03-31T04:26:32.907Z",
  "modified": "2026-03-31T04:26:35.731Z",
  "4.2.0": "2026-03-30T05:57:32.867Z",
  "4.2.1": "2026-03-30T23:59:12.278Z",
  "0.0.1-security": "2026-03-31T03:25:11.636Z",
  "0.0.1-security.0": "2026-03-31T04:26:33.055Z"
}
"security holding package"

The detail that matters is ordering. Socket's malware detection flagged plain-crypto-js@4.2.1 at 00:05:41 UTC, six and a half minutes after it was published and 16 minutes before the poisoned axios@1.14.1 that depended on it existed. A defense that reads package contents had a verdict before anyone could have installed axios@1.14.1. A defense that waits for a CVE never would: malware gets no CVE.

Which defenses stop which phishing lures?

#

Origin-bound authentication stops every phishing lure that ends at a login page and none that end in code execution. Analyzing the published package stops the payload regardless of how the maintainer was fooled. The incidents above sort cleanly:

IncidentLureWhat the attacker capturedWebAuthn on the maintainer accountInspecting the release before install
eslint-config-prettier, July 2025Email from npnjs.com linking to a cloned npm loginAn npm tokenStops itApplies: the versions carried a Windows DLL loader
num2words on PyPI, July 2025Verification email linking to a pypj.org proxyCredentials for four accounts, then two API tokensStops it, per PyPI's incident reportApplies: the uploads contained malware
chalk, debug, and 17 more (Qix), September 2025"Update 2FA Now" email from npmjs.helpPassword and a live TOTP codeStops itApplies: every version carried the same wallet drainer
DuckDB, September 2025The same npmjs.help proxyA session, then a new API tokenStops itApplies: the same wallet drainer
crates.io users, September 2025"Infrastructure compromised" email from rustfoundation.devGitHub credentialsStops itNot reached: no malicious crates were reported
axios, March 2026Cloned company, staged Slack workspace, fake meetingA RAT, then live npm and GitHub sessionsNo effectApplies: the dependency was flagged before axios@1.14.1 was published
Node.js maintainers, March to April 2026The same fake-meeting playbookAttempted malware installsNo effectNot reached: none of these attempts is known to have produced a release
Beamglea, October 2025HTML attachment loading an npm-hosted redirectOffice 365 passwords of non-developersNot applicable: the attacker used its own npm accountsApplies: the packages held redirect scripts with hard-coded victim emails

Our verdict: maintainers should treat a security key as the floor, not the finish. It closes the attack that worked against npm and PyPI in 2025, and the attackers have already moved on. Consumers should assume any maintainer can be phished and judge each new version by its contents. A dependency cooldown helps because the axios versions were live for about three hours. Blocking install scripts helps too: npm v12, released in July 2026, skips dependency lifecycle scripts unless you approve them, which would have stopped the plain-crypto-js postinstall dropper. It would not have stopped the Qix wallet drainer, which ran in end users' browsers when the poisoned code was bundled into websites. Package provenance and npm trusted publishing help by making an out-of-pipeline release visible, as axios@1.14.1 was. Neither one helps when the attacker publishes through the maintainer's real pipeline with the maintainer's real session.

Package registries as phishing infrastructure

#

Phishing also uses the supply chain in the other direction: registries serve as free, trusted hosting for ordinary credential phishing aimed at people who are not developers at all. Any public npm package is automatically served over HTTPS by CDNs such as unpkg.com and jsDelivr, from domains that corporate filters rarely block.

The Beamglea campaign, which our Threat Research Team documented in October 2025, published 175 npm packages with names like redirect-xs13nr. None ran code on install. Each held a redirect script that a purchase-order-themed HTML attachment loaded from unpkg.com, sending the victim to an Office 365 credential page with their email address pre-filled. The lures were addressed to more than 135 people across over 100 industrial, technology, and energy organizations.

A separate spearphishing campaign in December 2025 shipped entire lure pages inside 27 packages, aimed at 25 named individuals, mostly sales staff in manufacturing and healthcare. The pages blocked headless browsers, required real mouse or touch input before proceeding, and handed off to redirectors that match documented infrastructure for Evilginx, an open source AiTM toolkit that captures session cookies rather than just passwords.

For anyone inspecting packages, the signal is a mismatch between the package and its contents: an HTML login page, a document.write() that replaces the whole DOM, or a hard-coded email address and redirect URL in a package with no plausible library purpose. That is what a malicious package looks like when its victim never runs npm install.

What should change

#

Registries have made real progress. After the 2025 phishing wave, npm revoked classic tokens, blocked new TOTP enrollments, and capped write-token lifetimes at 90 days. PyPI's incident report told every maintainer to use WebAuthn. Two gaps remain from the attacks above.

First, sensitive actions should not trust an existing session. DuckDB's attacker turned a relayed session into an API token that outlived the phishing page. Creating a token, changing 2FA, or adding a maintainer should require a fresh WebAuthn assertion every time. A fresh TOTP prompt would not help, because the proxy relays it too. Publishers of widely used packages should be required to use origin-bound credentials, with TOTP removed rather than merely discouraged.

Second, no registry control distinguishes a maintainer from an attacker sitting on that maintainer's laptop. That is the attack that worked in 2026, and the people best placed to catch it are the ones about to install the result. The login page is no longer the perimeter. The maintainer's machine is, and the last check anyone controls is reading the code before it runs.