How to Detect AI Watermarks in ChatGPT and Claude Text
Generated text can carry invisible Unicode characters that survive copy and paste. What they are, why they break imports, and how to check before you ship.

The bug has a shape you only recognize the second time. Copy looks right in the doc and right in the CMS preview, then lands in an inbox with a stray space wedged into the middle of the product name. No diff explains it. Git blame points at a paste.
The usual culprit is a character that does not render: a zero width space that rode along when someone copied a paragraph out of an AI chat window into an editor. It was in the string the whole time, invisible in every tool used to look at it.
This is the part of shipping with AI writing tools that nobody puts in the launch post. Generated text often carries a second layer that has nothing to do with the words. Real Unicode code points, zero width and non printing, that survive copy and paste and travel into your CMS, your database, your JSON payloads, and your build.
What the invisible layer actually is
The characters involved are ordinary members of the Unicode standard. Zero width space (U+200B), word joiner (U+2060), the byte order mark (U+FEFF), bidirectional controls, variation selectors, and the Unicode Tags block. They exist for legitimate typographic reasons, which is exactly why they make a convenient carrier. A sequence of them encodes bits without changing a single visible glyph.
Vendors have been circling text provenance for a while, and several have said they embed or intend to embed signals in generated output. Some of that is statistical, baked into word choice, and leaves no artifact you can point at. Some of it is the literal invisible character kind, which you can find in about a second once you know to look.
Concretely, the surface is bigger than most people assume: 424 Unicode code points across six categories are plausible carriers for a hidden text watermark, counting zero width characters, bidirectional controls, invisible spaces, variation selectors, the Unicode Tags block, and a handful of format characters like the soft hyphen. Variation selectors and Tags account for 384 of those on their own.
Why a founder should care
Not for the reason you would guess. The interesting failures are boring and operational:
- Imports break. A zero width character inside a slug, a SKU, or an email address is a support ticket that reproduces on exactly one row.
- Search and dedupe silently miss.
AcmeandAc<U+200B>meare different strings to every index you own. Your dedupe job runs clean and your database keeps two customers. - Diffs lie. Two lines look identical in review and are not equal in production.
- Screen readers stumble. Stray control characters change how text to speech reads a sentence.
- Your copy carries a fingerprint you did not choose to publish. Whether you disclose AI assistance is your call. Having that decision made for you by a character you cannot see is a different thing.
How to check before you ship
The fastest path is to paste the text into an AI watermark detector and read what comes back. A good one names the exact code points, how many of each, and where they sit, rather than guessing at whether prose "sounds" machine written. Prefer one that runs client side for anything unpublished, so the text never leaves the browser.
If you would rather keep it in your pipeline, the check is a few lines in Node:
const SUSPECT = /[\u00AD\u034F\u061C\u115F\u1160\u180E\uFEFF\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFE00-\uFE0F]|[\u{E0000}-\u{E007F}]|[\u{E0100}-\u{E01EF}]/gu;
const hits = [...text.matchAll(SUSPECT)].map(m => 'U+' + m[0].codePointAt(0).toString(16).toUpperCase());
if (hits.length) console.warn('invisible characters:', hits);
Put it in the same pre commit hook that runs your linter. It costs nothing and it catches the paste before the paste becomes a customer.
For cleanup, strip the non printing characters and normalize the invisible space family (no break space, narrow no break space, ideographic space) back to a regular space so word boundaries survive. That last detail is where naive regex cleanup goes wrong: delete a no break space instead of replacing it and you silently glue two words together.
The six categories, and what each one is doing there
It helps to know why these characters exist, because that is what makes them useful as a carrier and awkward to remove blindly.
Zero width characters are the classic case. Zero width space, non joiner, joiner, word joiner, and the byte order mark. They were designed to control how glyphs join and where a line may break in scripts where that is not obvious from the letters. They render as nothing, they survive most copy paths, and a run of them encodes bits cleanly. If a watermark is going to hide anywhere, it hides here.
Bidirectional controls exist so that Arabic or Hebrew can be mixed with Latin text without the renderer guessing. Marks, embeddings, overrides, and isolates. They are invisible by design and they carry state, which is also why they show up in the occasional security writeup about source code that reads differently to a human than to a compiler.
Invisible spaces are the sneakiest group because they are not invisible at all. No break space, narrow no break space, medium mathematical space, ideographic space. They look exactly like a space and compare unequal to one. This is the category that breaks string matching without ever looking wrong on screen.
Variation selectors tell the renderer which form of a glyph to use, most familiarly whether an emoji renders as text or in color. There are 256 of them once you count the supplementary block, and they attach silently to whatever precedes them.
Unicode Tags are a deprecated block of 128 code points that mirror ASCII. Deprecated, still valid, still transmitted. In practice they are the most direct smuggling channel in Unicode: you can write a full ASCII string in tag characters and it renders as absolutely nothing.
Format characters round it out. Soft hyphen, combining grapheme joiner, Hangul fillers. Legacy typographic tools that no longer earn their keep in most modern copy.
Where these characters come from when it is not an AI
Worth saying plainly, because it changes how you should react to a hit. Invisible characters predate AI writing tools by decades and arrive from ordinary places:
- Word processors insert no break spaces around units and initials, and soft hyphens at line breaks.
- PDF extraction is a reliable source of exotic spaces, since the extractor has to guess word boundaries from glyph positions.
- Any content that has passed through a right to left language keeps bidi marks.
- Emoji anywhere in your copy brings variation selectors, entirely legitimately.
- Some CMSs and email builders inject a no break space to prevent an orphan word.
So a hit is not proof of anything about who or what wrote the text. It is proof that the string is not what it appears to be, which is the part that matters operationally. Treat the scan as a data hygiene check first and a provenance question second.
Auditing content you already shipped
If the check only runs going forward, everything already in the database stays broken. A one pass audit is cheap. For files (GNU grep, so ggrep on macOS):
grep -rlP '\x{200B}|\x{200C}|\x{200D}|\x{2060}|\x{FEFF}|\x{00AD}' content/
For a Postgres backed CMS, the same idea as a query:
SELECT id, title
FROM articles
WHERE body ~ ('[' || U&'\200B\200C\200D\2060\FEFF\00AD' || ']');
Run it once against titles, slugs, and any column that participates in a unique constraint or a join. Those are the columns where an invisible character changes behavior rather than just appearance. Body copy is mostly cosmetic; a slug is not.
The one place not to strip
Do not run a blanket strip over source code, structured data, or anything with a locale requirement. A no break space in French typography before a colon is correct. A variation selector inside an emoji sequence is load bearing, and removing it turns a colored emoji into a black and white glyph or breaks a family sequence into separate people. A zero width joiner is what holds those sequences together in the first place.
The rule that survives contact with real content: strip in prose destined for publication, normalize invisible spaces to a regular space rather than deleting them, and leave emoji sequences and code alone. Anything more aggressive trades a rendering bug for a data bug, which is a worse trade because the data bug is quiet.
What removal is and is not
Worth being precise, because the framing gets muddled by the AI detector industry. Stripping invisible characters is a formatting fix, not a disguise: it removes what the machine left behind without changing a word you wrote. It does not rephrase your sentences, it does not defeat a statistical watermark, and it will not move an AI content detector's verdict, because those tools are guessing at style rather than reading hidden bytes.
It also fails as forensics after the fact. Retyping, translating, or pasting through any plain text field tends to wipe the invisible layer on its own, so a clean scan of heavily edited text means very little. The scan is useful on fresh output, at the moment it enters your system, which is another argument for putting it in the pipeline rather than treating it as an investigation you run later.
The pre publish check that takes ten seconds
If you ship content, add one step between the AI window and your CMS: run the paste through a hidden character check, strip what it finds, then paste. It belongs with the other unglamorous checks that only get added after they bite you once, like confirming your OG image resolves and your sitemap returns 200.
The cheapest version of this habit is the one you never think about again. A hook, a paste step, ten seconds. The expensive version is the afternoon you spend reading a diff where both sides look the same.