ByteTips.

Guide

Invisible Characters Are Breaking Your Code: How to Find and Remove Them

Zero-width spaces and hidden Unicode characters sneak in through copy-paste and silently break string comparisons, logins, and builds. Here is how to spot and strip them.

October 16, 2026 · 5 min read · Shayan

The code looks perfect. The string looks right. The test still fails. Before you question your sanity, consider that the bug might be a character you literally cannot see: a zero-width space that rode in on a copy-paste from a doc, a chat message, or a website. Invisible Unicode characters are one of the most annoying classes of bugs in programming, precisely because everything renders normally while nothing matches.

The usual suspects

  • Zero-width space (U+200B) — the most common. Copy-paste from web pages, Notion, Google Docs, or chat apps drops these into identifiers, passwords, and config values.
  • Zero-width joiner and non-joiner (U+200C, U+200D) — these glue emoji sequences together; outside emoji they are invisible noise.
  • Byte-order mark (U+FEFF) — sits at the very start of files saved as UTF-8 with BOM. Breaks shebangs, JSON parsers, and CSV headers.
  • Word joiner (U+2060) and soft hyphen (U+00AD) — inserted by word processors and rich-text editors to control line breaks.
  • Bidirectional control characters (U+202A to U+202E) — the dangerous ones. They can reorder how code displays in an editor, making malicious code look benign. This is the 'Trojan Source' attack (CVE-2021-42574); most linters and compilers now flag it.

How to find them

Your eyes will not help — you need to inspect the code points, not the glyphs. In JavaScript, search for the known ranges, or dump the string as code points to see exactly what is in there:

// Detect the common invisible characters
const invisible = /[\u200B-\u200D\u2060\uFEFF\u00AD]/;
invisible.test("pass\u200Bword"); // true — a zero-width space hides after "pass"

// See the code points, not the glyphs
[..."na\u200Bme"].map((ch) => "U+" + ch.codePointAt(0).toString(16).toUpperCase());
// ["U+6E", "U+61", "U+200B", "U+6D", "U+65"] — the culprit is obvious now

The string-comparison trap

This is the failure mode that costs people hours: two strings that render identically but are not equal. "admin" and "admin\u200B" look the same in a log, in a diff, and in a database row — but === says false. Login forms, API keys pasted from dashboards, and unique constraints in databases all break this way. When a comparison fails and the two values look identical, check the code points before you rewrite the logic.

How to remove them

Once you know which characters to target, removal is one line. Strip the usual suspects with a regex, and treat the bidirectional controls separately — they have no legitimate use in source code or config, so flagging them is better than silently deleting them:

// Strip the usual suspects, keep normal text
const clean = (s) => s.replace(/[\u200B-\u200D\u2060\uFEFF\u00AD]/g, "");
clean("pass\u200Bword"); // "password"

// Bidirectional controls: warn loudly instead of silently stripping
const hasBidi = /[\u202A-\u202E\u2066-\u2069]/.test(s);
if (hasBidi) throw new Error("Refusing input with bidi control characters");

Try it

Paste suspicious text into the Invisible Character Cleaner — it highlights every hidden character it finds and strips them locally in your browser, nothing uploaded. If the invisible characters are inside a pattern you are debugging, the Regex Tester shows matches live so you can see exactly where they sit.

More guides