Duplicate usernames from Unicode normalization

Two accounts exist for josé. The list renders them identically. The uniqueness constraint did not fire, the login form matches whichever was created first, and support cannot explain why one customer’s password “stopped working” on a new device.

The two rows are different byte sequences that display the same: one holds a precomposed é, the other an e followed by a combining acute accent. Everything downstream — the unique index, the login lookup, the mention autocomplete — compared bytes.

How one name becomes two accounts A phone keyboard submits a decomposed form of an accented name, the signup stores it as typed, a desktop later submits the precomposed form of the same name, the uniqueness check finds no match, and a second account is created for what the reader considers one identity. Two accounts, one visible name Phone (NFD) Signup Database Desktop (NFC) josé — e + combining acute stored as typed josé — precomposed é no match — a second account is created
Both strings render identically. Only the byte comparison can tell them apart, and it is the one making the decision.

Root cause: input methods choose the encoding, not the user

Unicode allows an accented letter to be written as a single precomposed code point or as a base letter plus one or more combining marks. Both are valid, both render identically with any competent font, and which one arrives depends entirely on the software that produced it.

That software varies. Some mobile keyboards and some desktop input methods emit decomposed sequences; most emit precomposed. Copy-and-paste preserves whatever the source used, so a name pasted from a document may differ from the same name typed. Certain platforms normalize filenames to a decomposed form, so a value read from a file differs from the same value typed into a form.

The user is unaware of any of this and has no way to influence it. From their perspective they typed their name twice and got two different answers.

The consequences are not limited to duplicates. A login that matches on the stored form fails when the reader types the other form. An invitation to josé@example.com reaches nobody. A mention autocomplete lists one of the two accounts. A permission check compares an identifier against a list and misses.

Five comparisons an identifier policy has to answer A decomposed and a precomposed accent are the same person and are reconciled by canonical normalization. A fullwidth letter is the same person and needs compatibility folding. A case difference is the same person under case folding. Whether an accented and an unaccented spelling are the same person is a policy decision. A digit substituted for a letter is a different string and should stay one. What an identifier check must decide Same person? Handled by é vs e + ́ yes NFC normalization José vs Jose yes NFKC folding josé vs JOSÉ yes case folding josé vs jose policy decision accent folding — your call jose vs j0se no nothing — different characters
Only the fourth row is a judgement. The other four have technically correct answers.

Minimal reproducible example

const typed   = 'jose\u0301';   // e + combining acute — what one keyboard sent
const pasted  = 'josé';         // precomposed — what another sent

typed === pasted;               // false
typed.length;                   // 5
pasted.length;                  // 4
[...typed].length !== [...pasted].length;   // true — different code points

typed.normalize('NFC') === pasted.normalize('NFC');   // true

Insert both through a signup form with a unique index on the raw column and the database accepts both, because as far as it is concerned they are different values.

The fix: store two columns and index the derived one

Identity and display are different jobs, and trying to make one column do both is what produces the fork.

// The display name is exactly what the reader typed, normalized only canonically.
// The fold key is a derived value used for every comparison.
export function identityKey(input: string): string {
  return input
    .normalize('NFKC')      // canonical + compatibility: fi → fi, A → A
    .toLowerCase()          // locale-independent: identifiers must be stable
    .trim();
}

export function displayName(input: string): string {
  return input.normalize('NFC').trim();
}
ALTER TABLE users ADD COLUMN identity_key text NOT NULL;
CREATE UNIQUE INDEX users_identity_key ON users (identity_key);
-- The display name carries no constraint: two people may legitimately share one.

Two properties make this work. The unique index is on the derived key, so the database — not the application — enforces uniqueness, and a race between two signups cannot create a duplicate. And the display name is preserved exactly, so the reader sees their own spelling rather than a folded approximation.

Note the deliberate toLowerCase() rather than toLocaleLowerCase(). For an identifier you want the same result regardless of who is typing or where the code runs; the locale-sensitive form gives Turkish readers a different key for the same input, which recreates the problem in a new shape — the case-folding trap described in Unicode text handling and collation.

Five steps to a fork-proof identifier Input is normalized to composed form once at the boundary. A separate folded key is derived with compatibility normalization and case folding and stored alongside the display name. The unique index is on the folded key rather than on the display name. Every lookup path uses the folded key. And the display name is rendered exactly as the reader typed it. A username policy that cannot fork 1 Normalize to NFC on input at the API boundary, once 2 Derive a fold key for uniqueness NFKC + case fold, stored beside the display name 3 Unique-index the fold key, not the display name the database enforces it 4 Look up by fold key, always login, invite, mention, search 5 Show the name as the reader typed it folding is for matching, not display
The display name and the identity key are two columns doing two jobs — collapsing them is the original mistake.

Deciding how much to fold

NFKC plus case folding is the conservative default. Whether to go further — stripping accents entirely, so jose and josé are one identity — is a product decision with real arguments on both sides.

Folding accents prevents impersonation. An attacker registering josé to be mistaken for jose is a plausible attack on any system where a name conveys trust, and confusable-character attacks extend far beyond accents: Cyrillic а renders identically to Latin a in most fonts.

Folding accents destroys legitimate distinctions. In several languages an accent changes the word, and two people with genuinely different names would be told the name is taken. For a system where the identifier is a person’s actual name rather than a chosen handle, this is a poor trade.

The usual resolution is to fold aggressively for chosen handles — where uniqueness and trust matter and the user picks the string — and to fold only canonically for real names, where the value is descriptive rather than an identifier. Systems that need both keep them as separate fields, which is also the answer to the question of whether the display name may contain characters the handle may not.

Whatever the policy, it must be applied identically at signup, at login, at invitation, and in every lookup. A policy applied at signup only produces accounts that exist and cannot be found.

Retrofitting the key onto a live system

Adding a folded identity key to a table that already has millions of rows, active signups and a login path is a migration with an ordering that matters, because the intermediate states have to keep working.

Add the column and backfill it, without a constraint. The key is derived from data that already exists, so the backfill is a batched update and nothing depends on it yet. Adding the unique index at this point would fail on the first existing duplicate.

Start writing the key on every insert and update. New rows now carry a correct key while old ones are being backfilled, and the two can coexist because nothing reads the column yet.

Report the duplicates and merge them. The grouping query above produces the list. Most entries are the same person twice, and a few are genuinely different people whose names fold together — which is exactly the signal that your folding policy is too aggressive, and it is far better to learn that here than after the constraint is live.

Add the unique index concurrently. On PostgreSQL a concurrent index build avoids locking the table for writes; it will fail if any duplicates remain, which is why the merge comes first.

Switch the lookups over, one path at a time. Login, invitation, mention, search and any administrative tool each query by the key. Doing them individually keeps each change small and reversible.

The step teams are tempted to skip is the third, because merging accounts requires product decisions rather than code. Skipping it means the index build fails repeatedly and someone eventually relaxes the folding to make it pass — which recreates the original bug with extra steps.

Verification

test('normalization variants collapse to one identity', () => {
  const forms = ['josé', 'jose\u0301', 'JOSÉ', 'josé'];
  const keys = new Set(forms.map(identityKey));
  expect(keys.size).toBe(1);
});

test('the display name keeps the reader own spelling', () => {
  expect(displayName('jose\u0301')).toBe('josé');   // NFC, not folded
});
-- Find existing forks before adding the constraint
SELECT lower(normalize(display_name, NFKC)) AS k, count(*), array_agg(id)
FROM users GROUP BY k HAVING count(*) > 1;

That query is the first thing to run on an existing system: the constraint cannot be added until the duplicates it would have prevented are merged, and the list is usually shorter than people fear.

When to escalate

If duplicates persist after the constraint exists, some path is inserting without deriving the key — a data import, an administrative tool, a background job. The derivation belongs in one place that every writer goes through, not in each writer.

If a merge is needed, treat it as an account merge rather than a text problem: which account keeps the identifier, what happens to the other’s data, and how the affected people are told. The text fix prevents new forks and does not resolve old ones.

If the identifier is an email address, remember that the local part is technically case-sensitive by specification and case-insensitive in practice at every major provider. Folding it is what users expect; documenting that you do is what keeps the decision defensible.

FAQ

Should I normalize in the database instead?

Both databases and applications can normalize, and doing it in the application at the boundary is usually clearer because the boundary is where the untrusted value arrives. What must be in the database is the constraint, since only the database can enforce uniqueness under concurrency.

Does this affect passwords?

Yes, and it is worth handling deliberately: a password typed on a decomposed keyboard hashes differently from the same password typed precomposed, so the reader cannot log in from one of their devices. Normalizing the password to NFC before hashing fixes it, and doing so on an existing system requires accepting both forms during a transition.

What about names in scripts without case?

Case folding is a no-op for them, and normalization still matters — many scripts have multiple valid encodings of the same text. The policy applies unchanged; only the case-folding step becomes irrelevant.

Is String.prototype.localeCompare an alternative?

For matching, a collator with sensitivity: 'base' answers a similar question and is the right tool for search. It is the wrong tool for a uniqueness constraint, because a database index needs a stored, comparable key rather than a comparison function.

Part of Unicode Text Handling & Collation.