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.

0 characters

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

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.