September 7, 2026 Β· 8 min read
Why Text From AI Tools Contains Invisible Characters
Narrow no-break spaces and zero-width characters turn up constantly in text from chatbots. Here is what they are, why the watermark theory is probably wrong, and which ones actually matter.
Paste something from a chatbot into a code editor and a string comparison fails for no visible reason. Search a document for a word you can plainly see and get no match. The usual cause is a character that occupies the space of a space without being one β most often U+202F, the narrow no-break space. Our invisible character detector lists what is actually in a piece of text.
Quick answer
| Character | Code point | What it does |
|---|---|---|
| Narrow no-break space | U+202F | Looks like a space, is not one. The common one in AI output |
| No-break space | U+00A0 | Same, arrives from web pages and word processors |
| Zero width space | U+200B | No width at all, still counts as a character |
| Zero width joiner | U+200D | Combines emoji into one glyph β do not strip blindly |
| Right-to-left override | U+202E | Can reorder how text displays. A security concern |
| Tag characters | U+E0000βU+E007F | Invisible everywhere. Can hide an entire message |
The watermark theory is probably wrong
From 2025 onwards people noticed odd spacing in language-model output and concluded it was a hidden signature identifying AI-written text.
There is no evidence for that, and two arguments against it.
It would be trivially defeated. Any watermark made of invisible characters disappears the moment someone pastes the text into a cleaner β including this one. Nobody designing a serious provenance system would build it on something a single paste removes.
There is a duller explanation that fits better. These models are trained on enormous amounts of web text, and published web text is full of typographic spaces: thin spaces around punctuation in French typesetting, non-breaking spaces before units, narrow spaces in numerals. A model that reproduces the distribution of its training data will reproduce those too. It is not signing anything; it is copying what it saw.
The practical problem is identical either way, which is the useful thing to focus on.
Why it breaks things
The failures are all the same failure wearing different clothes: two strings look identical and are not equal.
"hello world" === "helloβ―world" // false β and both render identically
"hello world".includes("hello world") // depends entirely on which space is which
That shows up as:
- Searches that miss. Ctrl+F for a word you can see, and find nothing.
- CSV columns splitting wrongly, because a non-breaking space is not the delimiter the parser expects.
- Code that fails to compile, or worse, compiles with a string that is subtly not the one you meant.
- Duplicate rows that will not deduplicate, because two visually identical values differ by one byte.
None of these announce themselves. That is what makes them expensive β the failure is invisible in exactly the same way its cause is.
The two kinds that genuinely matter
Everything above is untidiness. These are different.
Bidirectional controls (U+202AβU+202E, U+2066βU+2069) exist for a real purpose: mixing Arabic or Hebrew with Latin text requires telling the renderer which direction applies. They also allow text to be displayed in an order different from how it is stored.
In source code this is the basis of Trojan Source: a comment that appears to end where it does not, so a human reviewer reads one program while the compiler builds another. The code passes review because the review was of something else.
Unicode tag characters (U+E0000βU+E007F) are stranger. They render as nothing in every font on every system, and the block maps onto ASCII β so an entire message can be encoded in characters that are visible to no one.
This matters most where text is read by a machine. A sentence that looks harmless to a person can carry a completely different set of instructions to a language model processing the same string. If you are pasting text from an untrusted source into an AI tool, that is worth checking first.
Do not strip everything invisible
This is where a lot of cleaning tools cause damage.
The zero-width joiner (U+200D) is not noise. It is what combines separate emoji into one: a family emoji is several people joined by it. Remove it and one glyph becomes three.
The zero-width non-joiner (U+200C) is part of correct spelling in Persian and matters in several Indic scripts. Stripping it does not tidy the text, it misspells it.
A cleaner that removes everything invisible will silently corrupt both. Ours keeps them by default and makes removal an explicit choice.
Why character counts disagree
A last piece of trivia with real consequences. JavaScript strings are sequences of 16-bit units, and anything above U+FFFF takes two of them β including every Unicode tag character.
const tag = "\u{E0041}";
tag.length // 2 β UTF-16 units
[...tag].length // 1 β actual characters
Code that walks a string one unit at a time sees two meaningless halves rather than one character. It will both miscount and fail to detect exactly the characters that matter most. Counting by code point is the fix, and it is why a detector's count may not match your editor's.
Check any text with the invisible character detector β it lists every code point it finds, flags the two dangerous categories separately, and returns a cleaned copy. Nothing you paste is uploaded or stored. For what else your setup reveals, see what your user agent says and the connection report.