There are characters you cannot see A developer building a travel site implemented a defense against invisible prompt-injection attacks by sanitizing all user text before it reaches an LLM, stripping Unicode Tag block characters, zero-width and bidi controls, and control characters while capping repeated-character runs and input length. The approach also wraps untrusted text in randomly generated XML-style fences and enforces output validation in code, treating model responses as untrusted as the input. Some Unicode characters render as nothing. No glyph, no space, nothing on your screen. A model reads them as text. That is the whole problem in one sentence. If an LLM reads what users write, every user is talking to your AI. Some will try to give it orders. The plain ones write "ignore previous instructions". The clever ones hide the order in characters you cannot see. On my travel site, models read everything travellers write. Reports, questions, answers, edits. Here is how I made the attempt useless. Not resisted. Useless. Every text goes through one function before it reaches any prompt. This is the core of it, verbatim: export function sanitizeForLLM text: string, maxLength: number, preserveJoiners = false : string { let s = text.normalize "NFC" ; // Tag block surrogate-pair range , zero-width, bidi, controls. s = s.replace / \u{E0000}-\u{E007F} /gu, "" ; s = s.replace preserveJoiners ? / \u200B\u200E\u200F\uFEFF\u202A-\u202E\u2066-\u2069 /g : / \u200B-\u200F\uFEFF\u202A-\u202E\u2066-\u2069 /g, "" ; s = s.replace / \u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F /g, "" ; // Collapse absurd repeat runs any code point, incl. astral via 'u' flag . s = s.replace / \p{Any} \1{10,}/gu, "$1$1$1$1$1$1$1$1$1$1" ; s = s.slice 0, maxLength ; // slice cuts UTF-16 code units — drop a trailing lone high surrogate. return s.replace / \uD800-\uDBFF $/, "" ; } Line by line, what it kills: The Unicode Tag block. A range of characters that map one to one onto ASCII and render as nothing. You can write a full sentence in it. Your screen shows a blank. A model reads the sentence. Gone. Zero-width characters and the bidi controls. Invisible, or they flip the reading direction of what follows. Nothing a traveller needs in a trip report. Gone. Control characters, except newline and tab. Gone. Runs of the same character, capped at ten. A thousand "a"s is a token bomb. It costs money and does nothing else. A hard length cap. No input can blow up a prompt, whatever it contains. One exception, and it is the kind of detail that makes this real. Two of the zero-width characters are joiners. Some scripts need them to spell correctly, and emoji sequences need them. So the one function whose output goes back to the traveller keeps them. The analysis-only functions strip them. There, a joiner is only useful to an attacker. Stripping characters handles the invisible tricks. It does nothing against "ignore previous instructions" written in plain letters. For that, the text is wrapped: export function wrapUntrusted text: string : { open: string; close: string; wrapped: string } { const id = crypto.randomUUID .slice 0, 8 ; const open =