Two names, one account  / field guide
Practitioner field guide · 15 October 2026 · Security & Identity

Two names, one account: the bug lives in the equality function, not the lookup

Every system that stores a username, an email address, a package name or a file path has quietly chosen an equivalence relation: which strings count as the same name. This guide reconstructs that choice from Spotify's 2013 account hijack, GitHub's and Django's 2019 password-reset collisions, two Git CVEs seven years apart, a pair of CPython CVEs, the npm typosquatting record and the standards that were rewritten afterwards. A reader leaves able to specify the equality contract for an identifier, test it, and say where in a system it has to be enforced.

34 ledger rows 14 production systems 10 incidents Evidence through October 2026 Read: 34 min
01

The territory: deciding sameness is a design decision nobody writes down

The problem, stated without any technology's name: two pieces of a system must agree on whether two strings refer to the same thing, and they usually agree only by accident.

Why this topic, and why now: in August 2025 Ryan Barnett stood on the Black Hat USA stage and demonstrated, again, filters bypassed because a check ran before a normalization step (Barnett, 2025). Jonathan Birch had shown the same antipattern against URL parsers at the same conference six years earlier (Birch, 2019), and Spotify had written the definitive postmortem six years before that. A class of bug that keeps reappearing for twelve years is not a knowledge gap. It is an architecture gap: the decision about what counts as the same name is made implicitly, in several places, by library defaults, and nobody owns it.

The scope here is identifier equality: account names, email addresses used as identifiers, package and module names, repository paths and hostnames. The guide covers how that relation is chosen, stored, enforced and changed. It deliberately does not cover full-text search collation, display-only text rendering, or internationalised email transport (SMTPUTF8), each of which has different constraints and a different failure record.

629
Code points for which "lowercase, then NFKC" is not idempotent: the order Spotify's library used
6,565
Visual-confusable mappings in Unicode's UTS #39 data, version 17.0.0, up from 6,355 in 16.0.0
1,900
npm packages a lowercase twin could impersonate, six years after npm banned uppercase names
3
Extra passes RFC 8266 allows before a name that will not stabilise must be rejected
The finding that surprised me

The fix the standards bodies eventually wrote down is the workaround Spotify shipped in an afternoon in 2013. Spotify stopped trusting its canonicalizer and refused any username X unless X equalled canonical_username(X). Four years later the IETF's nickname profile, RFC 8266, told every implementer that its own rules are "not an idempotent procedure for all code points" and that they must re-apply them until the output stops changing, rejecting the input if it has not settled after three extra passes. Go's implementation runs the pipeline four times (x/text precis). The standard did not make canonicalization safe; it made the fixed-point check mandatory.

The second surprise is how much of the defensive code in the corpus is an emulation of somebody else's equality. Git ships a hand-written list of the sixteen code points macOS's HFS+ silently ignores, and the hashed NTFS 8.3 short name of .gitmodules (gi7eba), because the repository and the filesystem it is checked out onto disagree about which paths are the same file (utf8.c, path.c).

Read the incidents side by side and every one of them is the same shape. A name moves through several stages (typed by a person, checked at registration, stored, looked up, acted on, handed to something downstream such as a filesystem, a resolver or a mail server), and at least two of those stages decide sameness differently. I am calling this equality skew: the sources call it a canonicalization bug, a case-mapping collision, a normalization antipattern, typosquatting and CWE-180, but the mechanism is the same, and naming it is what lets an architect look for it on a diagram before it ships.

Figure 1 · Where the sameness decision is made, and where each incident broke

homoglyphs: npm crossenv, IDN apple.com

non-idempotent canon: Spotify 2013

rule changed later: npm 2017 to 2023

acts on input, not record: GitHub, Django 2019

filesystem or IDNA folds: Git 2014, 2021, CPython 2019

Human reads and types

Registration check

Stored key

Lookup by input

Action: reset, write, fetch

Downstream: FS, DNS, mail

homoglyphs: npm crossenv, IDN apple.com

non-idempotent canon: Spotify 2013

rule changed later: npm 2017 to 2023

acts on input, not record: GitHub, Django 2019

filesystem or IDNA folds: Git 2014, 2021, CPython 2019

Human reads and types

Registration check

Stored key

Lookup by input

Action: reset, write, fetch

Downstream: FS, DNS, mail

A name passes through at least six stages, and each one carries its own idea of equality. Every published incident in this guide sits on an edge between two stages that disagreed, not inside one stage; the two hexagons are where most fixes landed. Reconstructed from the postmortems and fixes in section 4.
Diagram source

Who has solved it, as opposed to patched it: the clearest production designs in the record are the Go module system, which refuses to let any storage layer decide case (x/mod); the Python packaging index, whose equality is one ASCII regex specified in PEP 503 and reused verbatim in PEP 685; crates.io, which widened equality to fold hyphen and underscore (RFC 940); Discord, which split one identifier into a strict handle and a free display name in 2023; and Chrome's IDN display policy, which is the only system in the corpus that decides sameness the way a human eye does.

02

How it is actually built: one canonical key, one wider gate, and the record as the only thing you act on

The common shape across the systems that stopped having this class of incident, with the points where they diverge.

Laid side by side, the systems that fixed the problem converge on six components. Not one of them got there by choosing a better normalization form. They got there by separating the jobs that a single lower() call had been quietly doing at once: deciding which characters are allowed, deciding which strings are the same key, deciding which strings are too similar to coexist, and deciding which representation an action is performed on.

Figure 2 · Reference architecture for an identifier namespace

yes

yes

no

no

no

yes

Raw input

Repertoire
allowed?

canon v2
map, normalize, fold

canon of canon
equals canon?

Skeleton collides
with existing?

Record: unique key,
display form, version

Actions read
the record only

Reject

yes

yes

no

no

no

yes

Raw input

Repertoire
allowed?

canon v2
map, normalize, fold

canon of canon
equals canon?

Skeleton collides
with existing?

Record: unique key,
display form, version

Actions read
the record only

Reject

Two relations, not one: a strict canonical key that defines identity, and a wider confusable skeleton that only ever blocks registration. Actions resolve to the stored record and never reuse the raw input. Reconstructed from Go x/mod, PEP 503/685, RFC 940, RFC 8266, Django's CVE-2019-19844 fix, npm's 2017 moniker rules and Chromium's IDN policy.
Diagram source

1 · Repertoire before equality

The cheapest equality function is the one that has very little to compare. PEP 503 names may only use ASCII letters, digits and .-_; Go module paths are ASCII-only "today", and the x/mod source lists the Unicode questions (Kelvin sign, combining marks) it has not answered yet as the reason. Discord's 2023 handles are lowercase letters, digits, period and underscore. Spotify's 2013 design accepted Unicode it could not canonicalize, which is the failure in one sentence.

Runs this way at: PyPI, Go, Discord

2 · One canonicalizer, fixed-point checked

One function computes the key, it is pinned to a Unicode version, and its output is tested for idempotence at write time. Twisted's nodeprep still declares "We require Unicode version 3.2" in the source; RFC 8266 makes the repeat-until-stable loop normative; Spotify made X == canon(X) a registration rule. The order inside the function matters: Django's comparison normalizes before it folds, per UTR #36.

Runs this way at: Go x/text, Django, Spotify

3 · A unique index on the key

Equality is enforced by the store, not by application code that remembers to call the normalizer. crates.io compares names case-insensitively so RUSTC-SERIALIZE cannot be uploaded beside rustc-serialize, and RFC 940 extended the same key to fold - and _. PEP 503 makes the normalized name the URL itself, and tells clients they "MUST request the normalized URL" rather than rely on redirects.

Runs this way at: crates.io, PyPI

4 · A wider gate that rejects, never merges

A second relation, broader than identity, blocks registrations that a human would confuse. npm refuses names that differ from an existing one "in punctuation only"; Chrome compares a hostname's UTS #39 skeleton against top domains and shows punycode on a hit. The gate's output is always "no", never "these are the same account". Merging on a fuzzy relation is how Spotify and GitHub lost accounts.

Runs this way at: npm, Chrome

5 · Act on the record

After a match, everything downstream uses the stored value. Django's 2019 fix says it plainly: the token "will be sent to the email address on record rather than the submitted address". The secondary accounts of GitHub's fix describe the same move. This one rule neutralises an entire class of collision even when the comparison is still too loose.

Runs this way at: Django, GitHub

6 · Model the strictest consumer

When a name is handed to a system with its own equality (a filesystem, a resolver), the producer must reject anything the consumer would alias. Git emulates HFS+ and NTFS on every platform at fsck and push time; CPython's _checknetloc runs the IDNA normalization in advance to see whether a separator would appear; Go escapes uppercase as !a so no case-folding store can merge two modules.

Runs this way at: Git, CPython, Go

Where the systems diverge is on how much of the human relation to bring into the machine. Chrome brings almost all of it, because its users read hostnames with their eyes and have nothing else to go on. PyPI and crates.io bring a sliver (case and separators). Go brings none: it would rather force github.com/!azure/azure-sdk-for-go onto disk than let a filesystem guess. The divergence is not taste. It tracks who reads the identifier: machines that copy and paste can live with a strict relation; people who retype from memory cannot, which is exactly the reason Discord gives for retiring discriminators and casing.

One more component is missing from almost every system and present in none of the published designs explicitly: a version number on the canonicalizer. Twisted pins Unicode 3.2; Python's standard library on this machine reports Unicode 14.0.0; the confusables file grew by 210 mappings between Unicode 16 and 17. Neither account says so, but a canonical key computed under one table and compared under another is equality skew across time rather than across components, and the only defence is to store which version produced each key so that a re-canonicalization can be planned rather than discovered.

03

The decisions that matter, and the condition that flips each one

Six forks, each taken differently by at least two systems in the record.

Decision: should identifiers accept Unicode at all?

Chosen
  • ASCII-only for machine identifiers: Go module paths, PEP 503 names, npm package names, Discord handles.
  • Go's source explains why: once Unicode is allowed, "not all case-fold equivalent distinct runes form an upper/lower pair" (K, k and the Kelvin sign fold together) and marks need normalization too.
Rejected
  • Unicode usernames with a stringprep profile, as Spotify ran, which it was "a bit proud of" and which produced the 2013 hijack.
  • Reason: every additional code point is a new pair to reason about, and the table changes yearly.
Flips when
  • People type the identifier in their own script and it is user-facing (a handle, a nickname). Then use a PRECIS profile with the fixed-point check, pin the Unicode version, and keep a separate free-form display name so the identifier can stay strict.

Decision: compare on the fly, or store a canonical key with a unique index?

Chosen
  • Store the key: PyPI's normalized URL, crates.io's case- and separator-insensitive name check, Spotify's canonical_username column.
  • The store enforces uniqueness even when a new code path forgets the normalizer.
Rejected
  • Normalize both sides at comparison time only, as Django's get_users still does over a candidate set.
  • Works for one query; does not stop two rows that are "equal" from both existing.
Flips when
  • The relation is expected to change and the data cannot be backfilled cheaply. Then compare on the fly behind one function, and still act only on the record found.

Decision: when two names are too similar, merge them or refuse the second?

Chosen
  • Refuse: npm's punctuation rule, Chrome's punycode fallback, Git's rejection of .GIT and git~1, PEP 685's "MUST raise an error" when two extras normalize alike.
Rejected
  • Merge: treat the similar input as the existing account and proceed, which is the GitHub and Django reset behaviour before 2019.
  • A merge on a wide relation hands an attacker the victim's identity.
Flips when
  • Never for security decisions. For lookup convenience (redirecting /Foobar/ to /foobar/), merging is acceptable only when the target is resolved through the strict key and PEP 503's rule holds: clients must not depend on it.

Decision: does a producer enforce its consumers' equality, or only its own?

Chosen
  • Enforce the strictest consumer's. Git rejects HFS+ and NTFS aliases on affected systems, checks them on push "on all platforms", and lets Linux users opt in (core.protectHFS, core.protectNTFS).
  • Go escapes case so the proxy never needs two casings of a URL path.
Rejected
  • Trust the platform: a Linux server storing a tree that only becomes dangerous when cloned onto macOS or Windows.
Flips when
  • Names never leave the system that minted them. The moment they are written to disk, put in a URL or emailed, the downstream relation is part of your contract whether you model it or not.

Figure 3 · Choosing the equality contract for a new identifier

no, machine-copied

yes

no

yes

yes

no

Do people type it
from memory?

ASCII repertoire,
case-preserving or escaped

Must it accept
non-Latin scripts?

ASCII, fold case and separators,
unique index on key

PRECIS profile, pinned version,
fixed-point check, display name split

Written to disk,
URL or mail?

Reject what the strictest
consumer would alias

Skeleton gate at
registration only

no, machine-copied

yes

no

yes

yes

no

Do people type it
from memory?

ASCII repertoire,
case-preserving or escaped

Must it accept
non-Latin scripts?

ASCII, fold case and separators,
unique index on key

PRECIS profile, pinned version,
fixed-point check, display name split

Written to disk,
URL or mail?

Reject what the strictest
consumer would alias

Skeleton gate at
registration only

Each terminal is an action. The two questions that decide most of the design are who types the identifier and whether it ever reaches a system with its own idea of sameness. Derived from the decisions above.
Diagram source
DecisionChosenRejectedBecauseFlips whenEvidence
RepertoireASCII for machine identifiersUnicode with stringprepCase folding is not a pairwise relation outside ASCIIUsers type names in their own scriptGo x/mod
Where equality livesStored key, unique indexCompare-time normalization onlyStore enforces it for every code pathRelation will change and cannot be backfilledRFC 940
Near-collisionsRefuse the newcomerMerge into the existing accountA merge on a wide relation is an identity transferNever, for security decisionsnpm 2017
Action targetRecord found by lookupRaw submitted inputNeutralises collisions even with a loose comparatorNo credible case foundDjango 3.0.1
Downstream equalityModel strictest consumerTrust the platformThe checker and the filesystem disagreeNames never leave the minting systemGit 2.2.1
Changing the relationNormalize and migrate existing namesApply only to new namesGrandfathered names reopen the gapOld names are frozen and read-onlyCheckmarx 2023

The last row needs the argument shown, because the sources disagree. npm in December 2017 chose to apply the lowercase rule only to new names, which is cheap and breaks nobody; Checkmarx found in May 2023 that the 3,815 grandfathered uppercase names had left 1,900 packages open to a lowercase twin until npm patched it. PEP 685 took a middle route for extras: writers must emit normalized names from now on, readers "SHOULD warn" on legacy ones, and the authors measured the blast radius first (73 clashing extra names across PyPI, three among valid names) before deciding grandfathering was safe. Discord took the expensive route and began moving every account to a new handle. The context that explains the split is whether the old names are still writable. npm's were, and that is why the gap was exploitable; PEP 685's only change is how tools read existing metadata, which is why measuring first was enough.

Figure 5 · Twelve years of the same lesson, relearned in public

2013 · Spotify reset hijack
fix: register X only if X equals canon of X

2014 · Git .Git and HFS+ aliases
fix: reject on push, all platforms

2015 · PEP 503 and Rust RFC 940
equality written down as one rule

2017 · npm crossenv, IDN apple.com
fix: punctuation gate, skeleton display; RFC 8266 fixed point

2019 · GitHub, Django, CPython
fix: act on the record, canonicalize then check

2021 · Git CVE-2021-21300, Trojan Source
the emulated list was incomplete

2022 to 2023 · PEP 685, npm uppercase gap, Discord
rule changes treated as migrations

2025 · WAF bypasses
still validating before canonicalizing

2013 · Spotify reset hijack
fix: register X only if X equals canon of X

2014 · Git .Git and HFS+ aliases
fix: reject on push, all platforms

2015 · PEP 503 and Rust RFC 940
equality written down as one rule

2017 · npm crossenv, IDN apple.com
fix: punctuation gate, skeleton display; RFC 8266 fixed point

2019 · GitHub, Django, CPython
fix: act on the record, canonicalize then check

2021 · Git CVE-2021-21300, Trojan Source
the emulated list was incomplete

2022 to 2023 · PEP 685, npm uppercase gap, Discord
rule changes treated as migrations

2025 · WAF bypasses
still validating before canonicalizing

The incidents do not get rarer; the fixes move from patching one comparator toward writing the relation down and checking it against existing names. Dates from the sources in section 6.
Diagram source
04

What broke in production: four classes of equality skew

Ten published incidents, grouped by which pair of stages disagreed. The design rule in each card is the part to carry into a review.

Class A · The canonicalizer disagrees with itself

The narrowest and most instructive class. One function, applied twice, gives two answers. Nothing else in the system needs to be wrong.

Figure 4 · Spotify, 2013: one reset, three accounts

AccountsReset serviceRegistrationAttackerAccountsReset serviceRegistrationAttackervictim bigbird getsthe attacker'spasswordregister ᴮᴵᴳᴮᴵᴿᴰcanon once gives BIGBIRD, store with attacker emailrequest reset for ᴮᴵᴳᴮᴵᴿᴰcanon once gives BIGBIRDreset link mailed to attackerredeem link carrying BIGBIRDcanon again gives bigbird
AccountsReset serviceRegistrationAttackerAccountsReset serviceRegistrationAttackervictim bigbird getsthe attacker'spasswordregister ᴮᴵᴳᴮᴵᴿᴰcanon once gives BIGBIRD, store with attacker emailrequest reset for ᴮᴵᴳᴮᴵᴿᴰcanon once gives BIGBIRDreset link mailed to attackerredeem link carrying BIGBIRDcanon again gives bigbird
The reset request and the reset redemption each canonicalized the name once. Because the function was not idempotent for code points outside Unicode 3.2, the two calls landed on different accounts. Reconstructed from Spotify's postmortem and verified against the computation in section 5.
Diagram source
Postmortem

Spotify: the canonical name was not canonical

AssumptionTwisted's nodeprep.prepare was idempotent, like lower(), so it could be reapplied safely as a name moved between services.
What happenedThe profile maps case first, then applies NFKC. Small-capital modifier letters outside Unicode 3.2 normalized to uppercase ASCII after the case map had already run, so the first pass gave BIGBIRD and the second bigbird. The input check for Unicode 3.2 was not performed: "nodeprep.prepare did not check the input, and neither did we."
Blast radiusArbitrary account takeover via password reset; found by a user on Spotify's forum. Spotify disabled account creation and searched for accounts with incorrect canonical names to find hijacked ones.
FixRegister X only if X equals canonical_username(X); report to Twisted. The library still pins "Unicode version 3.2" in its source.
Design ruleTest the fixed point, not the function. Any canonicalizer used for identity must satisfy canon(canon(x)) == canon(x) for every accepted input, and the check belongs at write time, where a failure is a rejection rather than a breach.
Standard

RFC 8266: the standard admits the same property

AssumptionA well-specified profile (PRECIS, the successor to stringprep) would be idempotent by construction.
What happened"Implementation experience has shown that applying the rules for the Nickname profile is not an idempotent procedure for all code points." Go's commit history records the specific case: a diaeresis decomposes to a space plus a combining mark, and a later pass's additional mapping rule removes the space (x/text commit e2fd467).
Blast radiusNo published incident; this is the defect caught in specification, four years after Spotify's.
FixApply repeatedly until stable; if not stable after three extra applications, reject. Go x/text sets the repeat count to four.
Design ruleAssume your normalization pipeline is not idempotent until you have run it to a fixed point over the whole repertoire you accept, and make non-convergence an error, not a loop.
SourceRFC 8266 §2.1 as quoted in Go x/text

Class B · The check and the action use different equality

The commonest class, and the one with the cleanest fix. A lookup matches on a loose relation, then the action is carried out on the raw input, which is not the thing that matched.

Disclosure

GitHub: the dotless i reset

AssumptionIf a case-insensitive comparison matches an account's email, the submitted address is that account's address.
What happenedAn address typed with Turkish ı compared equal to the victim's after case conversion ("ı".upper() is "I"), and the reset token was mailed to the typed address, which the attacker controlled (per secondary accounts, at a mail domain accepting that character).
Blast radiusAccount takeover for any GitHub user; disclosed by John Gracey of Wisdom in November 2019. Secondary write-ups disagree on whether GitHub upper- or lower-cased; the mechanism works either way.
FixReported by secondary accounts as requiring the stored address to match the input before sending; GitHub's own account is not public.
Design ruleNever send a secret to the input. After any match, the destination comes from the record.
Postmortem

Django: the same reset, three weeks later

AssumptionLower-casing is an adequate case-insensitive comparison for email identifiers.
What happened"By submitting a suitably crafted email address making use of Unicode characters, that compared equal to an existing user email when lower-cased for comparison, an attacker could be sent a password reset token for the matched account."
Blast radiusEvery Django site using the built-in reset form; CVE-2019-19844, announced with Django 3.0.1 on 18 December 2019.
FixTwo changes, and the second matters more: compare with NFKC then casefold (UTR #36 §2.11.2(B)(2)), and send the token "to the email address on record rather than the submitted address".
Design ruleFix the action before you fix the comparator. A correct comparator is a moving target; acting on the record is not.

Class C · The producer and the downstream consumer disagree

The name is fine where it was checked, and becomes something else when a filesystem, a URL resolver or a mail server applies its own folding.

Source

Git, 2014: .Git/config on a laptop

AssumptionA path is a byte string; two different byte strings are two different files.
What happenedA tree containing .Git/config, legal on Linux, "would have clobbered .git/config" when checked out on a case-insensitive filesystem. HFS+ also ignores zero-width joiners and bidi controls (.g‌it), and NTFS maps git~1 to .git.
Blast radiusOverwriting .git/config on checkout of a hostile repository (CVE-2014-9390); the release note describes only the clobbering, and the severity follows from config being able to name commands Git runs (my inference). Found by the Mercurial maintainers (Matt Mackall, Augie Fackler), whose tool shared the flaw.
FixReject such paths on affected systems; check them at fsck and on push "on all platforms" so hosting servers on Linux refuse them too. Git hard-codes the 16 HFS+-ignored code points and the hashed NTFS short names, and its own comment calls the emulation "not complete by any means".
Design ruleYour equality is the union of every consumer's. Enforce it where names enter the shared namespace (the push), not only where they are materialized.
Source

Git, 2021: the same seam, seven years on

AssumptionThe 2014 path checks had closed the case-insensitive filesystem problem.
What happened"On case-insensitive file systems with support for symbolic links, if Git is configured globally to apply delay-capable clean/smudge filters (such as Git LFS), Git could be fooled into running remote code during a clone."
Blast radiusCVE-2021-21300, RCE on clone for macOS and Windows users of Git LFS; fixes backported to fourteen release lines, v2.17.6 through v2.30.2.
FixPatched in checkout; found and fixed by Matheus Tavares with Johannes Schindelin.
Design ruleAn emulated equality is a list, and lists are incomplete. Every new feature that writes names (delayed checkout, symlinks) re-opens the question; put the consumer's relation in the test matrix, not only in a validator.
Source

CPython: the check that needed a second CVE

Assumptionurlsplit could find the host by splitting on ASCII separators, independent of what IDNA would later do to the string.
What happenedCharacters such as U+2100 (℀) normalize under NFKC to a/c, so a netloc that contains no slash when parsed gains one when resolved. Reported by Jonathan Birch (Microsoft) and Panayiotis Panayiotou on 16 February 2019 as CVE-2019-9636. The first fix skipped the userinfo before @; Red Hat's Riccardo Schirone reported the bypass on 3 June 2019 as CVE-2019-10160.
Blast radiusAny Python application validating a URL's host with urlsplit before fetching it; fixes in five branches, twice.
Fix_checknetloc pre-applies NFKC ("IDNA uses NFKC equivalence, so normalize for this check") and raises if any of /?#@: appears.
Design ruleValidate the string the consumer will see, which means canonicalize first and then check (the inverse of CWE-180). A partial emulation of the consumer is a second bug waiting.
Talk

WAFs, 2025: still validating before canonicalizing

AssumptionA filter in front of an application sees the same string the application acts on.
What happenedBarnett's Black Hat USA 2025 session catalogues decoding errors, truncation, confusables, case mappings and combining diacritics that turn an allowed string into a dangerous one after the filter, framed as CWE-180, "Validate Before Canonicalize".
Blast radiusGeneric; the talk reports XSS, SSTI and RCE outcomes and cites CVE-2024-4577. No single incident is quantified.
FixThe deck's framing implies canonicalize once, at the edge, and hand the canonical form downstream; the specific mitigations were not retrievable from this sandbox.
Design ruleA filter is a second implementation of the application's equality. Either share the canonicalizer, or forward only the canonical form.

Class D · The machine and the human disagree, or the rule changed under existing names

Here every component agrees, and the identifier is still wrong, because the person reading it uses a wider relation than any code does, or because the relation was changed while old names stayed live.

Postmortem

npm crossenv: a name one hyphen away

AssumptionDistinct package names are distinct packages, and users will type the one they mean.
What happenedFrom 19 July 2017 the account hacktask published lookalikes of popular packages; crossenv (for cross-env) posted environment variables to an attacker host.
Blast radiusAbout two weeks undetected; npm removed "about 40" packages (secondary counts run 37 to 39); crossenv saw nearly 700 downloads and, npm estimated, at most 50 real installs.
FixManual review tooling, then in December 2017 a structural rule: new names that differ from an existing one "in punctuation only" are refused.
Design ruleThe registration gate needs the human relation even when identity uses the machine one. Two relations, not one.
Research

npm, 2023: the grandfathered names

AssumptionMaking new names lowercase-only in 2017 removed case as an attack surface.
What happenedThe 3,815 existing names with capitals stayed, and "no mechanism was implemented to prevent new packages from copying them in all but capitalization".
Blast radius1,900 packages open to impersonation, including one with nearly 200,000 weekly downloads; patched by npm after the May 2023 report.
FixExtend the collision check to compare new names against existing names under the new relation.
Design ruleChanging the equality relation is a data migration. The new rule must be checked against every existing key, or it only protects the future.
Disclosure

Browsers, 2017: apple.com in Cyrillic

AssumptionBlocking mixed-script labels catches homographs.
What happenedXudong Zheng registered xn--80ak6aa92e.com, all Cyrillic, which Chrome, Firefox and Opera displayed as apple.com. A single-script label passed the mixed-script filter.
Blast radiusUndetectable phishing for users of three browsers; Zheng reports Chrome fixed in 59 (press reports said 58), Firefox users told to set network.IDN_show_punycode.
FixChrome's policy now adds whole-script confusable checks and a UTS #39 skeleton comparison against top domains before showing Unicode.
Design ruleVisual equivalence is a relation over whole strings, not over characters or scripts. Compute a skeleton and compare skeletons.
Paper

Trojan Source: the reviewer and the compiler

AssumptionCode review sees what the compiler compiles.
What happenedBidirectional control characters reorder displayed tokens, and homoglyphs make two identifiers look identical, so the human and the toolchain disagree about which name is which.
Blast radiusWorking examples in twelve languages; coordinated disclosure with 19 organisations; CVE-2021-42574 and CVE-2021-42694.
FixCompilers, editors and forges added warnings for bidi controls and confusable identifiers.
Design ruleWhere a human approves an identifier, render the canonical form the machine will use, or flag when display and identity diverge.

Read across the four classes and one pattern dominates. Not one of the ten fixes was "use a better normalization form". Django did change its comparator, but it also stopped trusting it. The structural fixes were all about placement: check the fixed point at write time (Spotify, RFC 8266), act on the record (Django, GitHub), enforce at the point of entry to the shared namespace (Git's push-time check, npm's registration gate), and treat a rule change as a migration over existing keys (npm 2023, PEP 685). An architect reviewing a design should ask where each of those four placements is, not which Unicode form the team picked.

The detection gap deserves its own sentence. Spotify learned of its bug from a forum post; GitHub from a bounty hunter; Git from a competing project's maintainers; npm from a developer's tweet; the uppercase gap from a vendor's research blog six years after it opened. No account in this corpus describes equality skew being found by monitoring. That is an Open finding in the sense the evidence rules use: nobody has published a production detector for it, and a reader should assume their own system would also learn about it from the outside.

05

Numbers you can plan against

There is no cost curve for this topic; the planning numbers are sizes of the relation, sizes of the exposure, and how long fixes took. Each is dated.

MetricValueAtContextAs ofSource
Code points where NFKC(lower(x)) is not idempotent629DerivedMap-then-normalize order, as in stringprep profiles; Python 3.11.17, Unicode 14.0.0; single code points only2026-10Script in section 7
Code points where NFKC(casefold(x)) is not idempotent637DerivedSame run; folding first does not help2026-10Script in section 7
Code points where casefold(NFKC(x)) is not idempotent0DerivedDjango's UTR #36 order; single code points only, strings not exhaustively tested2026-10Script in section 7
Non-ASCII code points that fold to one ASCII letter or digit998Derivedcasefold(NFKC(x)); each is a candidate alias for an ASCII identifier2026-10Script in section 7
UTS #39 confusable mappings6,355 / 6,565 / 6,712UnicodeVersions 16.0.0, 17.0.0, 18.0.0 development file; the visual relation grows every year2024 to 2026-08confusables.txt
Fixed-point limit3 extra passesIETFThen reject; Go runs 4 passes total2017-10RFC 8266 via x/text
npm names with uppercase, grandfathered3,815npmOf which 1,900 could be impersonated by a lowercase twin2023-05Checkmarx
Typosquat packages removedabout 40npmcrossenv: about 700 downloads, at most 50 real installs, about 2 weeks live2017-08npm
Extra-name clashes under PEP 503 normalization73 (3 valid)PyPIAcross all extras on PyPI, measured before standardizing2022-03PEP 685
Friend requests failing on discriminator or casingalmost halfDiscordCompany's stated reason for strict lowercase handles2023-05Discord
Time between the first and second CPython fixabout 3.5 monthsCPython2019-02-16 report to 2019-06-03 bypass report; five branches patched each time2019PSRT database
Git release lines patched for CVE-2021-2130014Gitv2.17.6 to v2.29.3 plus v2.30.22021-03Git notes
HFS+ ignorable code points Git emulates16GitHard-coded in next_hfs_char, self-described as incomplete2026-10utf8.c
Languages with working Trojan Source exploits12Cambridge19 organisations notified2021 to 2023USENIX 2023
Read these carefully

The four derived rows are mine, computed on this machine's Python, and they are bounded: they test each code point alone, not sequences, so they undercount strings that only misbehave in combination (RFC 8266's diaeresis-plus-space case is one). They will change with the Unicode version; rerun the script against the interpreter your service actually uses. Checkmarx's figures are a vendor's research and come from secondary retrieval; the npm counts differ by three between sources. No source publishes the operating cost of running a skeleton gate or a fixed-point check, and I have not estimated one: both are string operations at registration time, and the published systems treat them as free.

What to plan against: if your identifiers accept anything beyond ASCII, the relation you are choosing has on the order of a thousand aliases for your ASCII namespace alone, and about six hundred inputs on which the most common pipeline order disagrees with itself. Those are the test cases. If they do not appear in the test suite, the design has not been tested.

06

The evidence wall

Every source behind this page, graded. Filter by kind. GitHub-hosted sources were fetched directly; the rest came through search retrieval, as the ledger notes.

PostmortemSpotify2013-06

Creative usernames and Spotify account hijacking

The canonical write-up of a non-idempotent canonicalizer: one reset, three accounts, and a fix by fixed-point check at registration.

Carry forwardTest canon(canon(x)) == canon(x) at write time.
engineering.atspotify.com/2013/06/creative-usernames
PostmortemDjango2019-12

Django 3.0.1 release notes, CVE-2019-19844

Case-mapping collision in password reset; the fix changes the comparator and, more importantly, mails the address on record.

Carry forwardAct on the record, never on the input that matched.
github.com/django/django/.../3.0.1.txt
PostmortemGitHub / Wisdom2019-11

Hacking GitHub's Auth with Unicode's Turkish Dotless 'I'

Researcher's disclosure of the same class at GitHub; details here rest on secondary summaries because the page was unreachable from the research sandbox.

Carry forwardLocale-free, Unicode-aware folding is still not a delivery address.
dev.to/jagracey/hacking-github-s-auth...
Postmortemnpm2017-08

crossenv malware on the npm registry

About 40 lookalike packages removed; crossenv exfiltrated environment variables for two weeks before a developer noticed.

Carry forwardRegistration needs a human-similarity gate, separate from identity.
blog.npmjs.org/post/163723642530
SourceGit2014-12

Git v2.2.1 release notes (CVE-2014-9390)

.Git, git~1 and .g‌it all alias .git on someone's filesystem; Git now checks on push on every platform.

Carry forwardEnforce consumers' equality where names enter the shared namespace.
github.com/git/git/.../2.2.1.adoc
SourceGitcurrent

utf8.c: next_hfs_char

Sixteen hard-coded code points that HFS+ ignores, with the maintainers' own note that the emulation is incomplete.

Carry forwardAn emulated equality is a list; budget for it being wrong.
github.com/git/git/blob/master/utf8.c
SourceGitcurrent

path.c: is_ntfs_dot_generic

NTFS trailing dots and spaces, 8.3 short names up to ~4, and hashed short-name prefixes such as gi7eba for .gitmodules.

Carry forwardFilesystems have equality relations as odd as Unicode's.
github.com/git/git/blob/master/path.c
SourceGit2021-03

Git v2.17.6 release notes (CVE-2021-21300)

Case-insensitive filesystems plus symlinks plus delayed checkout gave RCE on clone, seven years after the 2014 fix.

Carry forwardEvery new feature that writes names re-opens the equality question.
github.com/git/git/.../2.17.6.adoc
SourcePython PSRT2019

python-security vulnerabilities.yaml

CVE-2019-9636 and its bypass CVE-2019-10160: a host check that had to emulate IDNA's NFKC and got the emulation wrong once.

Carry forwardPartial emulation of a consumer is a second bug.
github.com/vstinner/python-security/...
SourceCPythoncurrent

Lib/urllib/parse.py: _checknetloc

The shipped fix: normalize with NFKC first, then refuse if a separator appears.

Carry forwardCanonicalize, then validate.
github.com/python/cpython/.../parse.py
SourceDjangocurrent

contrib/auth/forms.py: _unicode_ci_compare

NFKC then casefold, per UTR #36; the reset mail reads the stored address.

Carry forwardNormalize before folding; the reverse order is not idempotent.
github.com/django/django/.../forms.py
SourceTwistedcurrent

xmpp_stringprep.py

The library at the centre of the Spotify incident: map, then NFKC, then checks, pinned to Unicode 3.2.

Carry forwardA canonicalizer is versioned data; record the version with the key.
github.com/twisted/twisted/.../xmpp_stringprep.py
SourceGo2017 onward

x/text secure/precis/profile.go

Quotes RFC 8266's admission of non-idempotence and implements the fixed-point loop.

Carry forwardNon-convergence is a rejection, not a retry.
github.com/golang/text/.../profile.go
SourceGo2018 onward

x/mod module/module.go: Escaped Paths

Go encodes uppercase as !a so no case-insensitive store can merge modules, and keeps Unicode out until it can define normalization.

Carry forwardWhen storage folds, encode the distinction instead of trusting it.
github.com/golang/mod/.../module.go
SourceUnicode2025-07

confusables.txt (UTS #39), 17.0.0

6,565 visual-confusable mappings, up from 6,355; the 18.0.0 development file has 6,712.

Carry forwardA stored skeleton is only valid for the data version that made it.
github.com/unicode-org/unicodetools/...
ADR / RFCIETF2017-10

RFC 8266: nicknames (PRECIS)

The standard that makes the repeat-until-stable rule normative and caps it at three extra passes.

Carry forwardEven standards bodies could not make the pipeline idempotent.
rfc-editor.org/rfc/rfc8266
ADR / RFCPython2015-09

PEP 503: normalized names

One regex defines package identity; clients must request the normalized URL.

Carry forwardWrite the equality down in one line and make everyone use it.
github.com/python/peps/.../pep-0503.rst
ADR / RFCPython2022-03

PEP 685: comparison of extra names

Seven years after PEP 503, extras still had no equality; the fix reuses PEP 503, errors on collisions and measured 73 clashes first.

Carry forwardMeasure the collisions before changing a live relation.
github.com/python/peps/.../pep-0685.rst
ADR / RFCRust2015-03

RFC 940: hyphens considered harmful

crates.io identity folds case and, after this RFC, hyphen versus underscore; the compiler sees a separate crate name.

Carry forwardSplit identity from the representation each consumer needs.
github.com/rust-lang/rfcs/.../0940...
ADR / RFCChromiumcurrent

docs/idn.md: IDN display policy

Script-mixing rules, whole-script confusables and a skeleton match against top domains decide when to show punycode.

Carry forwardHuman equality is a whole-string skeleton comparison.
github.com/chromium/chromium/.../idn.md
PaperCambridge2023-08

Trojan Source: Invisible Vulnerabilities

Compiler and reviewer disagree about token identity via bidi controls and homoglyphs; 12 languages, 19 organisations notified.

Carry forwardShow humans the canonical form they are approving.
usenix.org/.../boucher
PaperWaseda / NTT2019-09

ShamFinder: detecting IDN homographs

Builds a homoglyph database (SimChar) beyond Unicode's confusables list and measures IDN homographs in the wild.

Carry forwardThe official confusables list is a floor, not a ceiling.
arxiv.org/abs/1909.07539
TalkMicrosoft2019-08

HostSplit: Exploitable Antipatterns in Unicode Normalization

Black Hat USA 2019. URLs that parse as one host and resolve as another; reported in Edge, .NET, Python, Java, Office 365 and Gmail.

Carry forwardRun ToASCII and fail on mismatch, as RFC 3490 already said.
i.blackhat.com/USA-19/.../Birch-HostSplit.pdf
TalkAkamai2025-08

Lost in Translation: Exploiting Unicode Normalization

Black Hat USA 2025. The same validate-before-canonicalize class against WAFs, six years after HostSplit.

Carry forwardA filter is a second copy of your equality; share it or forward the canonical form.
i.blackhat.com/BH-USA-25/.../Barnett.pdf
Blognpm2017-12

New package moniker rules

Names that differ from an existing one in punctuation only are refused; the gate rejects, it does not merge.

Carry forwardWiden the registration relation, not the identity relation.
blog.npmjs.org/post/168978377570
BlogCheckmarx2023-05

A new stealthier type of typosquatting attack, targeting npm

3,815 grandfathered uppercase names; 1,900 open to a lowercase twin. Vendor research.

Carry forwardA relation change that skips existing keys only protects the future.
checkmarx.com/blog/...
BlogDark Reading2023-05

Npm packages vulnerable to the Shift key

Independent account of the grandfathering gap and npm's patch.

Carry forwardCorroboration of the Checkmarx finding.
darkreading.com/threat-intelligence/...
BlogThe Register2017-08

This typosquatting attack on npm went undetected for 2 weeks

Independent account of crossenv with slightly different package counts.

Carry forwardLookalike names are found by people, late.
theregister.com/2017/08/02/typosquatting_npm
BlogDiscord2023-05

Evolving usernames on Discord

Split one identifier into a strict lowercase handle and a free display name, citing failed lookups on casing and discriminators.

Carry forwardSeparate the identity key from the display string.
discord.com/blog/usernames
BlogXudong Zheng2017-04

Phishing with Unicode domains

All-Cyrillic apple.com shown as Latin in three browsers because the filter only caught mixed scripts.

Carry forwardCompare skeletons of whole strings.
xudongz.com/blog/2017/idn-phishing
BlogHackaday2019-12

This Week in Security: Unicode, Truecrypt and npm

Secondary account of the GitHub dotless-i collision; disagrees with others on upper- versus lower-casing.

Carry forwardThe mechanism survives either casing direction.
hackaday.com/2019/12/20/...
07

Build a miniature, then productionise it

Six rungs. The first two take an evening; the line from toy to real is crossed at rung four.

Reproduce Spotify in ten lines

Write canon = lambda s: unicodedata.normalize("NFKC", s.lower()) and apply it to ᴮᴵᴳᴮᴵᴿᴰ once and twice. Then loop over every code point and count where canon(canon(c)) != canon(c); swap the order to NFKC-then-casefold and count again.

Done when: you get BIGBIRD then bigbird, and two counts that differ (629 versus 0 on Python 3.11).  Teaches: order inside the canonicalizer is a security property.

Write the equality contract down

For one identifier in your own system, write a one-page spec: allowed repertoire, the canonical function and its Unicode version, the wider registration relation, and which downstream systems receive the name.

Done when: a reviewer can answer "are these two strings the same account?" from the page without reading code.  Teaches: the relation is a decision, not a library default.

Enforce it in the store

Add a canonical-key column with a unique index and a canon_version column. Reject at write if the fixed point fails. Backfill existing rows and list the collisions before you enforce, as PEP 685 did.

Done when: inserting Foo_Bar beside foo-bar (or your relation's equivalent) fails at the database.  Teaches: equality enforced by convention is not enforced.

Make every action read the record

Audit every flow that matches on a name and then acts: password reset, invitations, ownership transfer, webhooks. Each must take its destination from the matched row, never from the request.

Done when: a test submits victim@gıthub.com and asserts the token goes to the stored address or nowhere.  Teaches: the cheapest fix in the corpus closes the commonest class.

Add the skeleton gate

Compute a UTS #39 skeleton (or an npm-style punctuation-and-case-stripped form) at registration and refuse on collision with an existing skeleton. Store the confusables data version beside it.

Done when: registering a Cyrillic lookalike of an existing name is refused, and a new confusables release produces a report of newly colliding names rather than silence.  Teaches: human equality is a second relation with its own lifecycle.

Model the downstream consumers

For each place a name is written (object keys, file paths, URLs, mail), build a test matrix that materializes names on a case-insensitive filesystem (a macOS or Windows CI runner) and through your URL parser, and asserts no two accepted names alias.

Done when: the matrix fails on .Git-style and U+2100-style inputs before your validator is fixed, and passes after.  Teaches: your equality is the union of your consumers'.

08

Keep hunting

The queries that found the material above. The vocabulary that unlocked this topic was "idempotent", "case mapping collision", "fixed point" and "skeleton".

Incidents and disclosures

  • "case mapping collision" password reset
  • canonicalization "not idempotent" account takeover
  • "compared equal" "lower-cased" CVE password reset
  • typosquatting registry postmortem "differ only" punctuation OR case

Source and standards

  • repo:git/git is_hfs_dotgit OR is_ntfs_dotgit
  • "apply the rules repeatedly until the output string is stable"
  • "normalized name" PEP comparison MUST raise an error
  • "case-fold equivalent" "Kelvin" module path

Talks and papers

  • Black Hat Unicode normalization "validate before canonicalize"
  • IDN homograph skeleton UTS 39 measurement paper
  • "Trojan Source" homoglyph identifier compiler warning

Your own stack

  • "Turkish locale" toLowerCase bug site:github.com is:issue
  • "case-insensitive" collision "unique index" migration username
  • NFKC casefold identifier comparison UTR 36 2.11.2
09

References

  1. Spotify Engineering, Creative usernames and Spotify account hijackingSpotify, June 2013. Checked 2026-10-15 (search retrieval).
  2. Twisted, xmpp_stringprep.pyTwisted trunk. Checked 2026-10-15.
  3. Go project, x/text secure/precis/profile.goGo, current. Checked 2026-10-15.
  4. P. Saint-Andre, RFC 8266, PRECIS nicknamesIETF, October 2017. Checked 2026-10-15 (quoted via Go x/text).
  5. John Gracey, Hacking GitHub's Auth with Unicode's Turkish Dotless 'I'DEV Community, 2019. Checked 2026-10-15 (search retrieval of secondary summaries).
  6. Hackaday, This Week In Security: Unicode, Truecrypt, And NPM VulnerabilitiesHackaday, 20 December 2019. Checked 2026-10-15.
  7. Django, 3.0.1 release notes, CVE-2019-19844Django, 18 December 2019. Checked 2026-10-15.
  8. Django, contrib/auth/forms.pyDjango main. Checked 2026-10-15.
  9. Git, v2.2.1 release notesGit, December 2014. Checked 2026-10-15.
  10. Git, utf8.cGit master. Checked 2026-10-15.
  11. Git, path.cGit master. Checked 2026-10-15.
  12. Git, v2.17.6 release notes, CVE-2021-21300Git, March 2021. Checked 2026-10-15.
  13. Python Security Response Team, vulnerabilities.yamlpython-security, 2019 entries. Checked 2026-10-15.
  14. CPython, Lib/urllib/parse.pyCPython main. Checked 2026-10-15.
  15. Jonathan Birch, HostSplit: Exploitable Antipatterns in Unicode NormalizationBlack Hat USA, August 2019. Checked 2026-10-15 (search retrieval; slide numbers unavailable).
  16. Ryan Barnett, Lost in Translation: Exploiting Unicode NormalizationBlack Hat USA, August 2025. Checked 2026-10-15 (search retrieval; slide numbers unavailable).
  17. Xudong Zheng, Phishing with Unicode DomainsPersonal blog, 14 April 2017. Checked 2026-10-15.
  18. Chromium, IDN in Google Chrome (docs/idn.md)Chromium main. Checked 2026-10-15.
  19. Unicode Consortium, confusables.txt 17.0.0 (UTS #39 data)Unicode, 22 July 2025. Checked 2026-10-15.
  20. N. Boucher and R. Anderson, Trojan Source: Invisible VulnerabilitiesUSENIX Security, August 2023. Checked 2026-10-15.
  21. H. Suzuki et al., ShamFinder: An Automated Framework for Detecting IDN HomographsACM IMC 2019 / arXiv, September 2019. Checked 2026-10-15.
  22. npm, crossenv malware on the npm registrynpm blog archive, August 2017. Checked 2026-10-15.
  23. The Register, This typosquatting attack on npm went undetected for 2 weeksThe Register, 2 August 2017. Checked 2026-10-15.
  24. npm, New package moniker rulesnpm blog archive, 26 December 2017. Checked 2026-10-15.
  25. Checkmarx, A new stealthier type of typosquatting attack spotted, targeting NPMCheckmarx, May 2023. Checked 2026-10-15.
  26. Dark Reading, Npm packages vulnerable to old-school weapon: the Shift keyDark Reading, May 2023. Checked 2026-10-15.
  27. D. Stufft, PEP 503, Simple Repository APIPython, September 2015. Checked 2026-10-15.
  28. PEP 685, Comparison of extra names for optional distribution dependenciesPython, March 2022. Checked 2026-10-15.
  29. Rust RFC 940, hyphens considered harmfulRust, March 2015. Checked 2026-10-15.
  30. Go project, x/mod module/module.goGo, current. Checked 2026-10-15.
  31. Discord, Evolving usernames on DiscordDiscord, May 2023. Checked 2026-10-15.