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 not flat, and the reason is in the next section: in o200k_base the 32
tag characters that mirror ASCII below 0x40 cost 3 tokens each and
the 64 that mirror 0x40 and above cost 4; in cl100k_base all 96 cost
3. A zero-width space costs one token in both encodings, so a tag character is
three to four times as expensive as the invisible character most people actually use. A 10-word
sentence with one tag character after each space went from 11 tokens to 56 — nine invisible
characters, 45 extra tokens.
The bytes are not hiding anything
A tag character is U+E0000 plus the ASCII character it stands for, and that sum is
not a scramble. UTF-8 writes it back out in the open: every tag character is four bytes, and the
last two of those bytes are the ASCII character itself wearing a continuation-byte mask.
UTF-8(U+E0000 + n) = F3 A0 (80 + (n >> 6)) (80 + (n & 0x3F))
so n = ((byte3 - 0x80) << 6) | (byte4 - 0x80)
| ASCII | Tag form | UTF-8 bytes | Last two bytes, mask removed |
|---|---|---|---|
| U+0020 space | U+E0020 | F3 A0 80 A0 | 0x00, 0x20 |
| U+002D hyphen | U+E002D | F3 A0 80 AD | 0x00, 0x2D |
| U+0037 digit 7 | U+E0037 | F3 A0 80 B7 | 0x00, 0x37 |
| U+0041 capital A | U+E0041 | F3 A0 81 81 | 0x01, 0x01 |
| U+0061 small a | U+E0061 | F3 A0 81 A1 | 0x01, 0x21 |
| U+007F (CANCEL TAG) | U+E007F | F3 A0 81 BF | 0x01, 0x3F |
Checked one code point at a time across the whole block: 96 out of 96 follow that formula, no exceptions. The block is invisible because no font draws it, not because anything concealed it. Anything that works at the byte level therefore reads the message without trying — a hex dump, a wire capture, a byte-wise diff, or a tokenizer that falls back to one token per byte.
Reading the message back out of the tokens
That fallback is the part worth knowing. A byte-level tokenizer cannot merge a code point it has
never seen, so it spends one token on each of the four bytes above and the pattern survives into
the token stream. Hiding confidential-draft-v7 and encoding it with o200k_base gives
81 token IDs; translating each ID back to the bytes it stands for gives this:
token IDs 175 254 223 96 | 175 254 223 107 | 175 254 223 106 | ...
bytes F3 A0 81 A3 | F3 A0 81 AF | F3 A0 81 AE | ...
mask off -- -- 01 23 | -- -- 01 2F | -- -- 01 2E | ...
'c' 'o' 'n'
Run the formula over every group of four bytes and the whole message comes back: 21 characters out of 21, in order, exact. The token stream is not a lossy summary of the text — for this block it is the text in another notation, which is why a model can act on words that never reach the screen.
It also explains the cost. Those 21 characters are 18 letters that mirror ASCII at
0x40 or above, at four tokens each, plus two hyphens and a digit at three:
81. A character that mirrors a space or a piece of punctuation is cheaper to hide
than one that mirrors a letter.
Paste the carrier from the top of this page and the tool hands back
confidential-draft-v7 together with the four bytes of each tag character it found.
Paste a line with none in it and it reports zero — the check is a range test on
U+E0000 to U+E007F, and a range test is the only thing that finds this
block reliably.
What a copy carries
Most hidden text travels by copy-paste, so it is worth knowing what the clipboard actually gets. Measured in Chromium 153 by selecting text, copying through the real clipboard and reading it back: of 25 invisible characters copied out of rendered text, 24 arrive unchanged and one does not — and the one that differs is not deleted, it is rewritten.
| Copied from | What lands on the clipboard |
|---|---|
| Rendered text on a page | A + U+0020 + B — the non-breaking space U+00A0 comes back as an ordinary space |
| A form field, selected and copied | A + U+00A0 + B — unchanged |
Tag characters are nowhere in that exception. The 39-code-point carrier from the top of this page
comes back as 39 code points whether it is copied out of rendered text or out of
a form field, and pasting it into a <textarea> delivers 39
again. So the reassuring idea that a browser quietly strips invisible characters on the way to the
clipboard is not what happens: nothing is stripped, one character is silently rewritten, and which
one depends on whether you copied a paragraph or a field. The practical version is short —
if you want to know what is in a string, do not clean it by copying it somewhere else. Read the
bytes.
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.
Spotting them in a file you already have
Pasting one string into the tool above works for a line you already suspect. For a whole file — a document export, a chat log, a database dump — run the same range test over the file instead of opening it and eyeballing it, because eyeballing is exactly what these characters defeat. Three ways, all run on this machine on the same test file (the 39-code-point carrier from the top of this page plus one plain line) and all agreeing:
# Python - count them and read the message back
import re
TAG = re.compile(r"[\U000E0000-\U000E007F]")
text = open("file.txt", encoding="utf-8").read()
print(len(TAG.findall(text))) # 21 on the test file
print("".join(chr(ord(c) - 0xE0000) for c in TAG.findall(text)))
# -> 'confidential-draft-v7'
# ripgrep - how many, and on which lines
rg -o '[\x{E0000}-\x{E007F}]' file.txt | wc -l # 21 on the test file
rg --count-matches '[\x{E0000}-\x{E007F}]' file.txt
// Node - same range test, one line
const t = require("fs").readFileSync("file.txt", "utf8");
(t.match(/[\u{E0000}-\u{E007F}]/gu) || []).length; // 21 on the test file
One tool does not work, and it is the one most people reach for first: GNU
grep -P refuses the range outright —
character code point value in \x{} or \o{} is too large — because its
\x{} escape cannot address a supplementary-plane code point (checked with GNU grep
3.11). Use ripgrep instead; it handles the range natively. Checked 2026-11-11 with Python
3.12.3, ripgrep 14.1.0 and Node 24. If the file turns out to be a document rather than plain
text, the same range test applies after export: the Word cleaner
strips tag characters from pasted document text, and the hidden text
checker reports them line by line.
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, updated 2026-11-11 (file-scan commands added, verified
2026-11-11), 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,
Word cleaner,
hidden text checker,
JSON cleaner,
Excel cell cleaner.