How do I sanitize user input before passing it to an LLM?
The short answer is that you can't sanitize your way to safety, and the useful version of this question is what to do instead.
Sanitization works for SQL and HTML because those languages have a parser with a grammar. You can escape a quote and know, with certainty, that the database will treat the result as a value rather than as syntax. A prepared statement is a guarantee: the parser physically cannot promote that string to code.
A language model has no parser and no grammar. It has a context window, and everything in it competes for influence. There is no character you can escape that makes a sentence stop reading like an instruction. "Ignore your previous instructions" is not dangerous because of its punctuation.
So the input layer is worth building, and it's worth building with accurate expectations. It does four things:
- Constrains what can reach the model at all, through length and format limits.
- Structures the request so instructions and data occupy different positions.
- Screens for recognizable attack shapes with a classifier.
- Normalizes encodings so a payload can't hide from step 3.
None of those is a boundary. Together they remove most of what's actually attempted, and they buy nothing at all against the attack that reads like a normal request. That's why the input layer is one of five in preventing prompt injection in LLM applications, not the answer on its own.
Why do regex filters and escaping fail?
Because the attack surface is meaning, and a regular expression matches characters.
Take the canonical filter, a deny list containing "ignore previous instructions". Here are ways past it, none of which requires expertise:
- Rephrase. "Disregard everything above." "Your prior guidance no longer applies." "New instructions supersede the old."
- Translate. The same sentence in another language, which the model understands and your English deny list doesn't.
- Encode. Base64, ROT13, URL encoding, or a request that the model decode something before acting on it.
- Substitute characters. Homoglyphs from other Unicode blocks, zero-width joiners between letters, unusual whitespace.
Ñ–gnorewith a Cyrillic Ñ– is a different string to your regex and the same word to the model. - Split across turns. Send the payload in fragments over several messages and let the conversation reassemble it.
- Don't attack at all. Write a sentence that a real customer might write, which happens to cause the action you want.
Escaping fails for a related reason. Escaping a quote in SQL changes how a parser categorizes it. Escaping a quote in a prompt produces a prompt with a backslash in it. The model reads the sentence either way.
Length limits are the one input constraint that genuinely helps, and they help for an unglamorous reason: many payloads need room. A 500-character field is a real constraint on an attacker who wanted to paste a document. Set limits based on what your feature actually needs.
What does structural separation look like?
The strongest available version of "sanitization" is not modifying the text. It's controlling where the text sits.
Use the message roles the API gives you. Your instructions belong in the system prompt. User text belongs in a user message. Retrieved documents belong in their own message, labeled as reference material. Concatenating all three into one string discards the only structural signal available:
// Throws away the separation the API offers.const prompt = `${systemInstructions}\n\nContext: ${doc}\n\nUser: ${input}`;
// Keeps it.const messages = [ { role: "system", content: systemInstructions }, { role: "user", content: `<reference_material>\n${doc}\n</reference_material>`, }, { role: "user", content: input },];Delimit untrusted spans and say what the delimiter means. Wrapping content in tags does nothing by itself. Wrapping it and telling the model in the system prompt that content inside those tags is data to analyze and never instructions to follow is what gives the delimiter meaning.
Strip the delimiter from the content first. If you wrap in <reference_material> and the attacker's document contains </reference_material>, they've closed your tag and are writing in your voice. Remove or escape occurrences of your own delimiter before wrapping. This is the one place where escaping genuinely helps, because you control both sides.
Use a delimiter that isn't guessable. A random token per request, rather than a fixed tag an attacker can read about in your public docs.
Microsoft's spotlighting technique is the stronger form: interleave a marker throughout the retrieved content rather than only at its edges, so the model can identify the untrusted span even if the content tries to break out mid-document. See Microsoft's guidance on defending against indirect prompt injection.
Structural separation is a hint with no enforcement behind it. It's still worth doing, because the majority of real attempts are not sophisticated.
Why normalize encodings first?
Because a classifier reads what you hand it, and a payload that's been transformed doesn't look like a payload.
Normalize before you screen, not after:
- Unicode normalization (NFKC) folds compatibility characters and many homoglyph tricks into their canonical forms.
- Strip zero-width and control characters, which exist in text almost exclusively to defeat matching.
- Decode what your application will decode anyway. If a downstream step base64-decodes a field, screen the decoded value, not the encoded one.
- Collapse unusual whitespace so
i g n o r edoesn't read differently to your detector than to the model.
The ordering error is common and quiet: normalize after screening and the detector has been looking at a disguised string all along.
Don't try to decode everything speculatively. Decoding arbitrary base64 out of user text produces noise and its own failure modes. Decode where your application's own semantics say a value is encoded.
Where does a classifier fit?
After normalization, before the provider call, on every untrusted string rather than only the chat box.
A trained detector is doing something the previous layers can't: scoring text by what it appears to be trying to do, rather than by what characters it contains. That catches rephrasing and translation, which is exactly where deny lists fall over.
import arcjet, { detectPromptInjection, shield } from "@arcjet/next";
const aj = arcjet({ key: process.env.ARCJET_KEY!, rules: [shield({ mode: "LIVE" }), detectPromptInjection({ mode: "LIVE" })],});
export async function POST(req: Request) { const body = (await req.json()) as { message?: unknown }; if (typeof body.message !== "string" || body.message.length > 4000) { return new Response("Invalid message.", { status: 400 }); }
const message = body.message.normalize("NFKC");
const decision = await aj.protect(req, { detectPromptInjectionMessage: message, });
if (decision.isDenied()) { return new Response("Please rephrase your message.", { status: 400 }); }
return Response.json({ reply: await callProvider(message) });}On Express and Hono the package is @arcjet/node and the check goes after the body parser, since middleware that runs before parsing has no body to inspect:
import arcjet, { detectPromptInjection } from "@arcjet/node";import express from "express";
const app = express();app.use(express.json({ limit: "64kb" }));
const aj = arcjet({ key: process.env.ARCJET_KEY!, rules: [detectPromptInjection({ mode: "LIVE" })],});
app.post("/chat", async (req, res) => { const raw = req.body.message; if (typeof raw !== "string" || raw.length > 4000) { res.status(400).json({ error: "Invalid message." }); return; }
const message = raw.normalize("NFKC");
const decision = await aj.protect(req, { detectPromptInjectionMessage: message, });
if (decision.isDenied()) { res.status(400).json({ error: "Please rephrase your message." }); return; }
res.json({ reply: await callProvider(message) });});Two things to be exact about, because vendors usually aren't.
Where the detection runs. Arcjet's prompt-injection rule is server-side: the text is sent to the Arcjet Cloud API, because a specialist model makes the decision. This is unlike Arcjet's sensitive-information detection, which runs in-process and never transmits the body. If the prompts themselves carry a residency obligation, that's a question to answer on its own terms. The control-by-control table says what each rule transmits.
False positives are not hypothetical. Any product whose users legitimately discuss prompts, instructions, system behavior, or security will generate them. A developer tool is the worst case: "how do I stop users overriding my system prompt" is a support question and reads like an attack. Run in dry-run mode against real traffic, read what it flags, and only then enforce.
What about payloads assembled across turns?
This is the case single-message screening structurally cannot see, and it's worth understanding before you conclude you're covered.
The attacker sends fragments. Message one is a harmless-looking string. Message two is another. Message five asks the model to concatenate the pieces it was given and follow the result. Each message scores clean on its own, because each message is clean on its own.
Partial answers, in rough order of cost:
- Screen what you're about to send, not what you just received. If you send the full conversation on every turn, screen the assembled payload. It costs more and it sees what the model sees.
- Screen the model's own plan. Where your agent produces a plan or tool call before acting, that artifact is a better detection target than the messages that produced it, because it's where the effect becomes visible.
- Watch for divergence. If the operation the agent is about to perform has no relationship to what the user originally asked for, that's a signal available without understanding the attack.
- Rely on the action gate. This is the honest answer. A payload assembled over five turns still has to produce a tool call, and that call still has to pass a check that doesn't consult the model.
Anyone claiming to solve multi-turn injection with input screening alone hasn't thought about it very hard.
Which inputs count as untrusted?
More than the chat box, and this is where most implementations are thin.
- The user message. Covered by everyone.
- Uploaded files, where the payload is in a PDF or spreadsheet rather than the message.
- Retrieved documents and RAG chunks. Your application chose them, which is not the same as them being trustworthy.
- Tool results, including responses from third-party APIs and MCP servers.
- Fetched web pages.
- Email and ticket bodies ingested without a human reading them.
- Filenames, metadata, and profile fields that get interpolated into prompts.
- Transcribed audio.
The rule that covers all of these: if a string ends up in the context window and you didn't author it, screen it. For the retrieved-content half of that list see how to defend against indirect prompt injection in agentic workflows, and for framework wiring see prompt injection protection for LangChain, LlamaIndex, and Vercel AI SDK apps.
What should a blocked user see?
Something generic, with a way forward, and no detail about the detector.
Don't say which rule fired, don't echo the flagged span, and don't return a score. Every one of those teaches someone probing your filter what to change. Return the same shape on the API as in the UI, because the API is what gets probed.
Log the block with the entity or rule that fired, the actor, and a correlation identifier. Don't log the flagged text into a store with a long retention period and broad read access, or the detector becomes its own disclosure. The same reasoning as logging a PII block without logging the PII applies here.
Checklist
- Set length and format limits based on what the feature needs.
- Normalize Unicode and strip zero-width characters before screening, not after.
- Keep instructions, retrieved content, and user text in separate messages.
- Strip your own delimiter from untrusted content before wrapping it.
- Screen with a classifier in dry run first, and read the false positives.
- Screen every untrusted string, not only the chat box.
- Screen the assembled payload where the client replays history.
- Keep denial responses generic and identical across API and UI.
- Put an action gate behind all of it, because the input layer is not a boundary.
Frequently asked questions
How do I sanitize user input before passing it to an LLM?
Constrain length and format, normalize encodings, keep instructions and data in separate messages, and screen with a classifier before the provider call. What you cannot do is escape your way to safety: sanitization works for SQL because a parser has a grammar, and a language model has neither.
Why don't regex filters stop prompt injection?
Because the attack surface is meaning and a regular expression matches characters. A deny list on "ignore previous instructions" is defeated by rephrasing, translating, encoding, homoglyph substitution, splitting the payload across turns, or simply writing a plausible business instruction that causes the action you want.
What does structural separation actually look like?
Use the message roles the API gives you rather than concatenating everything into one string. System instructions in the system prompt, user text in a user message, retrieved documents in their own labeled message. Wrap untrusted spans in a per-request random delimiter, strip that delimiter from the content first, and say in the system prompt what the delimiter means.
Should I normalize encodings before or after screening?
Before. Apply Unicode NFKC normalization, strip zero-width and control characters, and decode anything your application will decode anyway. Normalizing after screening means the detector was looking at a disguised string the whole time.
Can input screening catch a payload split across several messages?
Not by itself. Each fragment is clean on its own. Partial answers are screening the assembled payload you're about to send rather than the message you just received, screening the model's plan instead of its inputs, and checking for divergence from the original request. The reliable answer is the action gate, which a multi-turn payload still has to pass.
AI runtime security in your code
Protect your AI agent workflows with Arcjet
Get allow, deny, and redact on agent actions before the side effect.