Unicode tag characters: an invisible copy of the ASCII alphabet
Short answer: U+E0020 to U+E007F are 95 invisible characters that mirror
printable ASCII, one for one. Anything you can type in ASCII you can hide inside a
sentence where no one sees it. Measured here: hiding the 20-character string
confidential-draft-v7 in a normal-looking line renders 0 px wide in
Chromium, survives all four Unicode normalization forms, survives every
zero-width cleaner we threw at it, and costs 81 tokens instead of 6 once it
reaches a tokenizer. The 21-character payload also turns an 18-character line into a
39 code point, 102 byte string that still looks like
Board report final. It is not a glitch in one tool — it is a second alphabet that happens
to have no glyphs.
What the block actually contains
The names are the giveaway. Read them straight out of the Unicode database (Python 3.13,
unicodedata 15.1.0):
| Code point | Unicode name | Category | UTF-8 | UTF-16 | Printable? |
|---|---|---|---|---|---|
| U+E0000 | — unassigned, no name — | Cn | 4 bytes | 2 units | No |
| U+E0001 | LANGUAGE TAG | Cf | 4 bytes | 2 units | No |
| U+E0020 | TAG SPACE | Cf | 4 bytes | 2 units | No |
| U+E0041 | TAG LATIN CAPITAL LETTER A | Cf | 4 bytes | 2 units | No |
| U+E0061 | TAG LATIN SMALL LETTER A | Cf | 4 bytes | 2 units | No |
| U+E007E | TAG TILDE | Cf | 4 bytes | 2 units | No |
| U+E007F | CANCEL TAG | Cf | 4 bytes | 2 units | No |
The block was defined to carry language tags, and its characters are named for the ASCII
characters they sit at: U+E0000 + n is the tag form of ASCII character
n. It never entered general use, which is exactly why it is useful for hiding
things: a character that is in the standard, valid in a string, and has no glyph is a very good
place to put a message.
Hiding a message takes four lines
Add 0xE0000 to each ASCII code point and you have invisible text. Subtract it and you have the message back:
# Python 3 - verified output shown below
msg = "confidential-draft-v7"
hidden = "".join(chr(0xE0000 + ord(c)) for c in msg)
carrier = "Board report" + hidden + " final"
print(len(carrier)) # 39 - code points
print(len(carrier.encode())) # 102 - UTF-8 bytes
print(len("Board report final")) # 18 - what the reader actually sees
print(len(hidden.encode())) # 84 - the payload alone, 4 bytes per character
# recovery from a round trip through a file
back = open("out.txt", encoding="utf-8").read()
"".join(chr(ord(c) - 0xE0000) for c in back if 0xE0000 <= ord(c) <= 0xE007F)
# -> 'confidential-draft-v7'
The same thing in JavaScript, where the length surprise is bigger because each tag character is a surrogate pair:
const hidden = [...msg].map(c => String.fromCodePoint(0xE0000 + c.codePointAt(0))).join("");
const carrier = "Board report" + hidden + " final";
carrier.length; // 60 UTF-16 code units
[...carrier].length; // 39 code points
new TextEncoder().encode(carrier).length; // 102 bytes
[...carrier]
.filter(c => /[\u{E0000}-\u{E007F}]/u.test(c))
.map(c => String.fromCodePoint(c.codePointAt(0) - 0xE0000))
.join(""); // 'confidential-draft-v7'
A textarea holding a shorter version of the same trick — Report + four
hidden characters + final — reports 19 characters where
11 are visible, and the payload survives a full
JSON.parse(JSON.stringify()) round trip unchanged.
What it costs when the text reaches a model
Tag characters sit in the supplementary plane, so each one is four UTF-8 bytes and the
tokenizer has no merge for it. Measured with tiktoken, both encodings:
| Hidden payload | Plain tokens | Tag-encoded (o200k_base) | Tag-encoded (cl100k_base) | Bytes added |
|---|---|---|---|---|
ACME-Q3 (7 chars) |
4 | 26 | 21 | 28 |
confidential-draft-v7 (21 chars) |
6 | 81 | 63 | 84 |
| 50 repeated characters | 7 | 200 | 150 | 200 |
The per-character rate is flat: 4 tokens per tag character in o200k_base, 3 in cl100k_base, measured at 1, 2, 4, 8 and 16 characters. That is roughly four times what a zero-width space costs, because a zero-width space is three UTF-8 bytes and a tag character is four, and neither has a merge. A 10-word sentence with one tag character after each space went from 11 tokens to 56 — nine invisible characters, 45 extra tokens.
Measured: nothing catches it
This is the part that matters. Every row below was run on this machine, not assumed:
| Operation | Tag characters removed? | Measured result |
|---|---|---|
| Unicode NFC / NFD / NFKC / NFKD | No | 3 tag characters in, 3 out, in all four forms |
| Removing the 11 usual invisible code points | No | U+200B, U+200C, U+200D, U+2060, U+FEFF, U+00AD, U+180E and friends all removed; tags untouched |
\s in a regex (Python and JS) |
No match at all | isspace() false, /\s/ false |
trim() / str.strip() |
No | They are not whitespace, so trimming leaves them |
JSON.stringify, then JSON.parse |
No | Round trip is byte-identical; Python json.dumps writes them as \udb40\udc41 surrogate escapes and they come back intact |
/[^\x20-\x7E]/g (strip everything non-ASCII) |
Yes | The only one of these that works — and it destroys every accented letter too |
Whitelist-free comparison: localeCompare |
Collapses them | "ACME".localeCompare("ACME" + tag) returns 0, while === says false |
That last row is the quiet one. Collation ignores format characters, so a database uniqueness check or a de-duplication pass can treat two different strings as the same while application code treats them as different. Which behaviour you get depends on which layer you are standing on.
Measured: it renders at zero width
Rendered in Chromium 153 at 16 px, measured two ways — element box width and canvas
measureText:
| Content | Rendered width | Characters actually present |
|---|---|---|
| Empty span | 0.00 px | 0 |
| One tag character | 0.00 px | 1 |
| Five tag characters | 0.00 px | 5 |
| One hundred tag characters | 0.00 px | 100 |
The letter A (baseline) |
10.32 px | 1 |
Report + 5 tags + final |
77.19 px | 16 (11 visible) |
A hundred invisible characters and the line does not move by a single pixel. There is no tofu box, no dotted placeholder, nothing to give it away on screen.
Measured: form fields accept it
Typed into real inputs in Chromium (not set programmatically):
- A
requiredfield containing only two tag characters passes validation —checkValidity()returnstruewhile the same field left empty returnsfalse.value.lengthis 4 because each character is two UTF-16 units, andvalue.trim()is not empty. - A field with
maxlength="5"accepted only 2 tag characters: the limit counts UTF-16 code units, not what a person would count as characters.
So "the field is not empty" is true, "the field has content" is false, and both are correct depending on which question you asked.
Find and remove them
The range is small and precise, so the fix is a single character class:
// JavaScript - count them
const count = (s) => (s.match(/[\u{E0000}-\u{E007F}]/gu) || []).length;
// remove them (and every other format character, if you want the whole class)
const clean = s => s.replace(/[\u{E0000}-\u{E007F}]/gu, "");
# Python - count and remove
TAG = re.compile(r"[\U000E0000-\U000E007F]")
print(len(TAG.findall(text)))
clean = TAG.sub("", text)
If you would rather not run the regex yourself, the Claude watermark remover on the home page flags U+E0001 and U+E0020–U+E007F and strips them; the zero-width space remover shows you where each invisible character sits in the string, and the invisible characters list has the rest of the code points by name. For payloads rather than prose, the JSON cleaner and Excel cell cleaner do the same job on structured input.
What this page does not tell you
Finding tag characters tells you they are there. It does not tell you who put them there, and it is not evidence that a text was produced by any particular model or tool — the block has legitimate uses in the standard, and a stray one can come from a bad conversion. The rendering, validation and length figures above were measured in Chromium 153 on Windows; other engines count code units the same way, but check before you rely on a number in production.
Measured 2026-09-22 with Python 3.13 (unicodedata 15.1.0),
tiktoken o200k_base and cl100k_base, Node 22 and Chromium 153 via Playwright.
Related: what invisible characters cost you in tokens,
U+2028 and U+2029 in JSON,
zero-width space remover,
invisible characters list,
JSON cleaner,
Excel cell cleaner.