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):

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.