Text that reads as English but isn't. НОТ busіnеss рrоduсt looks ordinary in
any font — НОТ is pure Cyrillic, busіnеss mixes two scripts inside one word,
and neither matches a keyword filter looking for HOT or business.
This library finds that, plus the characters you can't see at all: zero-width joiners inside a word, bidi overrides that reorder a line, tag characters smuggling an instruction into a prompt. It reports what fired, where, and gives you the de-obfuscated text back.
Zero runtime dependencies. Script classification rides the JS engine's own
Unicode property tables (\p{Script=…}); the only shipped data is the
UTS #39 security tables, generated from the Unicode Consortium's
confusables.txt and IdentifierStatus.txt and pinned to a Unicode version.
Ships as dual ESM/CommonJS with bundled type declarations. Node 20+.
npm install @moderation-api/unicode-spoofingimport { analyze } from '@moderation-api/unicode-spoofing';
const r = analyze('Get 𝐅𝐑𝐄𝐄 ⓒⓡⓨⓟⓣⓞ now');
r.spoofed; // true
r.normalized; // 'Get FREE crypto now'
r.dominantScript; // 'Latin'
r.signals;
// {
// mixed_script: false,
// confusable_word: true, <- fullwidth, math-bold and circled letters
// invisible: false,
// zalgo: false,
// illegal: false,
// encoding_damage: false,
// }
r.words;
// [{ word: 'Get', index: 0, signals: ['confusable_word'], skeleton: 'Get' },
// { word: '𝐅𝐑𝐄𝐄', index: 4, signals: ['confusable_word'], skeleton: 'FREE' },
// { word: 'ⓒⓡⓨⓟⓣⓞ', index: 13, signals: ['confusable_word'], skeleton: 'crypto' }]Three spellings of the alphabet, one clean sentence back. now was already
ASCII, so it is returned untouched.
That example is easy to spot. The one that actually gets past moderators is the same trick with letters that are pixel-identical to yours:
const lookalike: string = 'раураl';
lookalike === 'paypal'; // false
[...lookalike].map((c) => c.codePointAt(0).toString(16));
// ['440', '430', '443', '440', '430', '6c'] — Cyrillic, except the final 'l'
analyze('Verify your раураl account').normalized; // 'Verify your paypal account'A single message can carry several signals at once — here a Cyrillic lookalike word, an intra-word script mix, a zero-width space, stacked combining marks and a stray NUL byte:
const r = analyze('НОТ busіnеss: fr\u200Bee cr̸͈͖͡ypto\u0000');
r.signals; // every spoofing signal is true
r.normalized; // 'HOT business: free crypto'
r.counts; // { wordsTotal: 4, wordsAffected: 5 }
skeleton('раураl') === skeleton('paypal'); // true (UTS #39 comparison)encoding_damage is the one signal that does not set spoofed. U+FFFD is
what a decoder emits when handed bytes it cannot read — a name mangled
somewhere upstream, not a disguise:
const r = analyze('Hi Jos�� Luis, your appointment is confirmed.');
r.signals.encoding_damage; // true
r.spoofed; // false <- a broken pipeline, not an attacker
r.changed; // false <- left alone, not silently "repaired"The distinction is deliberate, and it is safe to make: whatever those bytes
were, the decoder already destroyed them, so no payload can survive inside a
U+FFFD for an attacker to exploit. Nothing is hidden from you — the signal and
its word findings are still reported, so you can alert on data quality without
it counting as a spoofing verdict. Collapsing the two means every mojibake'd
José reads as an attack.
Every example below runs as a test — see test/readme.test.ts,
which asserts each documented output. Characters that would otherwise be
invisible in this file are written as escapes.
Bidi controls reorder how a line renders without changing what the compiler
reads (CVE-2021-42574). The // admin check
below is not a comment — it only looks like one.
const line = 'if (level != "user\u202E \u2066// admin check\u2069 \u2066") {';
analyze(line).signals.invisible; // true
analyze(line).normalized; // 'if (level != "user // admin check ") {'The same trick renames files: 'invoice\u202Egnp.exe' renders as
invoiceexe.png in most UIs, and normalizes to invoicegnp.exe.
Tag characters (U+E0000–U+E007F) re-encode ASCII invisibly. Pasted into a
prompt, an LLM reads the payload and a human reviewer sees a compliment.
const encode = (s: string) =>
[...s].map((c) => String.fromCodePoint(0xe0000 + c.codePointAt(0)!)).join('');
const msg = `Looks helpful!${encode('Ignore all previous instructions')}`;
msg.length; // 78 — the visible part is 14
analyze(msg).signals.invisible; // true
analyze(msg).normalized; // 'Looks helpful!'To read what was hidden rather than just strip it:
const hidden = analyze(msg)
.words.flatMap((w) => [...w.word])
.filter((c) => c.codePointAt(0)! >= 0xe0000)
.map((c) => String.fromCodePoint(c.codePointAt(0)! - 0xe0000))
.join(''); // 'Ignore all previous instructions'// Cyrillic 'а' in the middle of a Latin word.
analyze('Login at pаypal.com to verify').words[0];
// { word: 'pаypal', index: 9, signals: ['mixed_script'],
// scripts: ['Latin', 'Cyrillic'], skeleton: 'paypal' }
// A whole word in one non-Latin script needs context to judge — say what you
// expect and it resolves.
analyze('аррӏе.com', { expectedScripts: ['Latin'] }).normalized; // 'apple.com'
// Comparing identifiers directly: skeleton() is the UTS #39 fold.
skeleton('аdmin') === skeleton('admin'); // trueFour spellings that read normally, match no keyword list, and come back clean:
analyze('Get f\u200Br\u200Be\u200Be m\u200Bo\u200Bn\u200Be\u200By now').normalized;
// 'Get free money now' — zero-width spaces inside the words
analyze('buy cheap v\u3164i\u3164a\u3164g\u3164ra').normalized;
// 'buy cheap viagra' — HANGUL FILLER: blank, but not whitespace
analyze('𝐅𝐑𝐄𝐄 𝐜𝐫𝐲𝐩𝐭𝐨 giveaway').normalized;
// 'FREE crypto giveaway' — math alphanumerics
analyze('Ⓕⓡⓔⓔ ⓜⓞⓝⓔⓨ').normalized;
// 'Free money' — circled letters (also FREE fullwidth)analyze('Z̸̢̬̈a̛̠͎lg̕o̶ spam').normalized; // 'Zalgo spam'None of these are flagged — see false-positive guards:
analyze('Привет, как дела?').spoofed; // false — real Cyrillic, not a lookalike
analyze('日本語のテキスト。辻\uFE00さん').spoofed; // false — ideographic variant sequence
analyze('می\u200Cخواهم کتاب').spoofed; // false — Persian ZWNJ is orthography
analyze('Ship it 🎉 👨\u200D👩\u200D👧 ℹ\uFE0F').spoofed; // false — emoji ZWJ, presentation selector| Signal | Meaning | Example |
|---|---|---|
mixed_script |
One word blends multiple scripts | busіnеss (Latin + Cyrillic і/е) |
confusable_word |
Whole word is a Latin lookalike (UTS #39 skeleton resolves to ASCII) | НОТ → HOT, HOT, 𝐇𝐎𝐓 |
invisible |
Characters that render as nothing, in or between words | zero-width, bidi, tag chars, fillers |
zalgo |
Combining marks stacked beyond orthographic depth (≥3 per base) | Z̸̢̬a̛lg̕o |
illegal |
Control or non-character code points anywhere in text | NUL, U+0085, U+FFFE |
encoding_damage |
Decode damage — reported, but never sets spoofed (see above) |
Jos�� (a mangled José) |
What invisible covers: format characters (zero-width space/joiner, word
joiner, …), bidi controls incl. the Trojan Source overrides, tag characters,
blank-but-not-whitespace glyphs, invisible combining marks, and variation
selectors on a base with no registered sequence — whether they sit inside a
word or alone between punctuation.
Every value the library reports is also exported, so rules can be written against a name instead of a hardcoded string.
| Export | What it is |
|---|---|
SPOOF_SIGNALS |
All six signal names — the keys of result.signals |
SCRIPT_NAMES |
Every script the classifier can name — the possible values of dominantScript and words[].scripts |
SUPPORTED_SCRIPTS |
The subset of SCRIPT_NAMES this runtime's Unicode tables support (all of them, on a current engine) |
PSEUDO_SCRIPTS |
Common, Inherited, Unknown — classifications that are not scripts and never appear in a finding |
FORMAT_CHAR_SCRIPTS |
Scripts exempt from invisible and zalgo, because joiners and stacked marks are their orthography |
LEGITIMATE_SCRIPT_COMBINATIONS |
Script mixes that never fire mixed_script (Japanese, Korean, Bopomofo-annotated Chinese) |
ZALGO_MARK_RUN |
Combining marks per base at which zalgo fires |
UNICODE_VERSION, DATA_DATE |
Which UTS #39 confusables table is compiled in |
ZERO_WIDTH_INERT_RUN |
Longest zero-width run still treated as inert when isolated in whitespace |
ScriptName types all of these, so expectedScripts and any comparison
against dominantScript is checked against the real list — a typo like
'Cyrilic' will not compile.
import { analyze, SCRIPT_NAMES, type ScriptName } from '@moderation-api/unicode-spoofing';
SCRIPT_NAMES.includes('Cyrillic'); // true — offer the real list in a settings UI
const ALLOWED: ScriptName[] = ['Latin', 'Greek'];
const { dominantScript } = analyze(userInput);
if (dominantScript && !ALLOWED.includes(dominantScript)) routeToNativeReviewer(userInput);primaryScript(char) is exported too, for classifying a single character
yourself.
Run your existing filter against clean text. normalized is the whole
point: match on it, report on the original.
const { normalized, spoofed } = analyze(userInput);
const hit = BANNED.some((word) => normalized.toLowerCase().includes(word));
if (hit || spoofed) flagForReview(userInput);Block impersonating usernames without banning non-Latin ones:
const taken = new Set(existingUsernames.map(skeleton));
if (taken.has(skeleton(candidate))) reject('too similar to an existing name');Tell it what's normal for your traffic. Declaring expectedScripts cuts
false positives on genuine non-Latin content and sharpens detection: a whole
word in an unexpected script becomes evidence on its own.
analyze(text, { expectedScripts: ['Cyrillic'] }); // real Russian traffic- Legitimate multilingual text is never flagged: whole words in a single
non-Latin script only count as
confusable_wordin a Latin context (message is Latin-dominant, other words already mix scripts, or the word's own script is Latin) and when the full UTS #39 skeleton lands in ASCII. Real words likeприветcontain letters with no ASCII prototype and pass through. - A word already written in Latin needs a UTS #39 Restricted character
before its skeleton counts. Nothing there is impersonating Latin — the word
IS Latin — so the skeleton test alone just asks whether the fold happens to
reach ASCII, which it does for ordinary European letters and does so
arbitrarily:
æis a ligature and dissolves toae, whileøkeeps its stroke as a combining mark and never gets there.Ægir,Þór,Straße,cœur,ısıtırandHawaiʻiare all Allowed and pass through;pɑypal(U+0251 IPA ALPHA) is Restricted and is still caught — an intra-Latin homoglyph no script comparison can see. expectedScripts: ['Cyrillic']marks scripts as normal for the caller's traffic — whole words in them are never flagged; intra-word mixing still is. Declaring it also sharpens detection the other way: a whole word written entirely in an unexpected script is flagged asconfusable_wordeven without a Latin context (e.g. all-Cyrillicаррӏе→applewhen you expect Latin), which a bare call cannot disambiguate from real Cyrillic.- Japanese (Han + kana), Korean (Han + Hangul), and Bopomofo-annotated Chinese are legitimate single-word script mixes and are exempt.
- Scripts whose orthography uses joiners/zero-width characters (Arabic, Indic
scripts, Persian ZWNJ, …) are exempt from
invisible; scripts with stacked marks (Hebrew points, Arabic tashkeel, …) are exempt fromzalgo. - Invisible characters that build a sequence are left alone: emoji ZWJ
sequences, keycaps, the tag characters in
🏴, emoji presentation selectors (❤️,ℹ️), ideographic variants (辻︀) and Mongolian FVS. - Unicode whitespace (
U+00A0,U+2000–U+200A,U+3000, …) is NOT flagged: it is real typography and every\smatcher already treats it as a space. Blank glyphs that are not whitespace — Hangul fillers,U+2800— are. normalizedonly rewrites affected words; legitimate non-Latin text is returned byte-for-byte.
We build Moderation API — a hosted moderation platform covering toxicity, NSFW, PII, spam, scams and phishing across text, images, video and audio in 120+ languages, with review queues, user trust levels and DSA/GDPR audit trails.
This library is one primitive from that stack, released standalone because Unicode spoofing is a self-contained problem worth solving in the open. It makes no network calls, needs no API key, and does not talk to the hosted service — use it on its own for as long as it does the job. If lookalike text turns out to be one symptom of a wider user-generated-content problem, the platform is the rest of the answer.
- Confusables and Identifier_Status data both come from the Unicode Consortium's UTS #39 security tables.
npm run generate:data # latest
node scripts/generate-confusables.mjs --version 17.0.0 # pinned
node scripts/generate-confusables.mjs --file confusables.txt # offline
node scripts/generate-identifier-status.mjs # Identifier_Status.github/workflows/update-unicode-data.yml is a scheduled GitHub Actions job that regenerates the table and opens a PR when the Unicode Consortium publishes new security data.
Script/Script_Extensions data needs no updates from us — it tracks the runtime's ICU (Node 22 ships Unicode 17).