Georgii EmelianovEngineering

Prompt injection when the model's output is a screen

Every defence written for this assumes the payload is a string. Change the output type to a UI tree and the escaping advice stops applying — while the attack surface gets larger, not smaller.

A shallow brass sieve with fine woven mesh, coarse dark gravel caught on top of it and fine pale sand fallen through underneath.

Your app renders what the model emits. Not a paragraph the model wrote — a screen: a heading, a metric row, a comparison, a button. Security asks the obvious question, which is what happens when a hostile string reaches that model through your own API's response payload. You go looking for the answer, and every page you find tells you to escape HTML entities.

That advice is correct for the pipeline it was written about and useless for yours. Indirect prompt injection in generated UI is not a string-escaping problem, because there is no string boundary to escape. The payload is the structure itself. This is the part of what a generated screen is structurally unable to do that the platform guidance has not caught up with, and it is worth understanding before you ship rather than during the review that blocks you.

The short version

Indirect prompt injection in generated UI is an attack in which hostile instructions, hidden in content the model reads rather than typed by the user, cause it to emit a valid but adversarial interface. Escaping cannot stop it, because the payload is structure rather than markup. The defence is a closed output contract.

Five consequences follow, in the order they matter:

  1. The payload is a screen, not a string. A malicious record is well-formed JSON containing no metacharacters. It survives every escaper ever written.
  2. Escaping and output-monitoring do not apply. A native renderer has no HTML and no eval, and a successfully injected screen does not look wrong — it looks like a screen.
  3. The defence is a contract with nowhere to put the attack. If the schema has no field for a URL, a colour, or a button label, the model cannot emit one regardless of what it was told to do.
  4. Actions are references, never payloads. The wire carries an identifier the host already declared; it never carries a destination.
  5. Every reference must be grounded in something a tool actually returned this turn, or the record is rejected.

Why "escape the output" is the wrong defence here

The official guidance is good, and it is aimed somewhere else. Google's Android developer documentation on mitigating prompt injection, last updated 5 August 2026, gives six mitigations. Its entire treatment of rendering is one line: escape standard HTML entities before rendering the model's response in your UI, to prevent XSS. Microsoft's security considerations for AG-UI says the same thing, in a document otherwise devoted to agent-to-UI protocol threats.

Both are describing a WebView. In a WebView, the model's text lands inside a document that will execute what it finds, so the defence is to neutralise the characters that cross from data into code.

A generative UI implementation on iOS that renders natively has no such boundary. There is no HTML, no script context, no interpreter waiting for a < to change its mind. Escaping there protects nothing that was ever at risk. Meanwhile the actual channel — the tree of records the renderer walks and turns into views — gets no scrutiny at all, because the guidance does not model it as a channel.

The input side is well covered and you should follow it. OWASP's LLM01:2025 Prompt Injection entry names the two shapes that matter: direct injection through the user's own text, and indirect injection through content the model reads. Apple's WWDC26 session 347, Secure your app: mitigate risks to agentic features, covers the iOS side of that: spotlighting untrusted content with delimiters, redacting sensitive data, and requiring user confirmation before a risky tool call. What none of them cover is the output type.

Escaping asks "could this text become code?" The question a generated screen needs is "could this structure become an instruction to the person reading it?"

What indirect prompt injection in generated UI can attempt that text cannot

This is the taxonomy the SERP does not have. Assume the attacker has already won the input battle: a hostile instruction has reached the model through a field in your own API's response, and the model is now cooperating with it. If the output is text, the attacker gets to write a paragraph. If the output is a screen, they get five more things.

A fake action. A paragraph can ask the user to call a phone number. A screen can put a button next to the request, in your app's own button style, under a heading in your app's own type. The user's trust is in the chrome, and the chrome is yours.

A fake destination. Any field that carries a URL is a redirect. The model chooses the string; the renderer makes it tappable; the user sees your app's link treatment. If the tree allows a free-form link anywhere, the attacker has a phishing surface inside an authenticated session.

A false claim wearing structure. A number in a metric row reads as retrieved data. A comparison row reads as a fact table. Text hedges itself and structure does not — "your balance is £0" in a metrics record carries the visual authority of something the backend returned, whether or not anything returned it.

An exfiltration channel. If the model can emit an image URL, a media reference, or a map pin, it can encode data from the conversation into that URL and the device will fetch it. No user interaction is required, and nothing in the rendered screen shows that it happened.

A fake authority. Tone, severity, and placement are signals. A warning callout at the top of a screen means something to a user. A model that can choose severity can manufacture urgency, which is the precondition for most of what an attacker actually wants next.

Notice what every one of those needs: a field in the contract to live in. That is where the defence goes.

Defence 1 — a contract with nowhere to put the attack

The strongest control available is the one the model cannot argue with. If the wire schema has no property for a link, a colour, or a free-form label, then no instruction reaching the model can produce one, because there is nowhere in a valid record to put it. OWASP's own prevention list gets close to this — define and validate expected output formats, and constrain model behavior — but stops at "use deterministic code to validate adherence". Adherence to what matters more than the checking.

A record definition that closes the surface looks like this. It is the shipped callout record from the Uzori wire schema:

{
  "type": "object",
  "additionalProperties": false,
  "required": ["type", "tone", "body"],
  "properties": {
    "type": { "const": "callout" },
    "tone": { "enum": ["neutral", "information", "success", "warning"] },
    "title": { "type": "string", "minLength": 1, "maxLength": 120 },
    "body": { "type": "string", "minLength": 1, "maxLength": 600 }
  }
}

Three things are doing the work. additionalProperties: false means an injected "url" or "style" key does not get ignored — it invalidates the whole record. tone is a four-value enum, so severity is a choice between four host-designed presentations rather than an open channel. And there is no colour, font, spacing or nesting field anywhere in the 23 records of the schema, so the model cannot author style at all; the theme lives in the client and is never sent to the server.

The test to run against your own renderer: take one record type and count the fields a model could put a URL, a hex colour, or arbitrary markup into. If the answer is more than zero, that is your attack surface, and it is not covered by escaping.

Defence 2 — actions are references, never payloads

The fake-action case is the one that turns a content bug into an incident, and it has a clean structural fix: the model never says what a button does. It says which of the host's declared actions to show. Here is the shipped handoff_bar record:

{
  "type": "object",
  "additionalProperties": false,
  "required": ["type", "actions"],
  "properties": {
    "type": { "const": "handoff_bar" },
    "actions": {
      "type": "array",
      "minItems": 1,
      "maxItems": 2,
      "uniqueItems": true,
      "items": {
        "type": "object",
        "additionalProperties": false,
        "properties": { "actionRef": { "type": "string", "maxLength": 160 } }
      }
    }
  }
}

There is no label, no URL, and no payload — only actionRef, an identifier the host application registered in advance. The label and behaviour are resolved on the device from the app's own configuration. An actionRef the app was not configured with is dropped by the client, and it does not even receive a button trait, so it is not tappable and not announced as tappable.

That is the component allowlist injection defence in its useful form. An allowlist of tool names on the input side is standard advice; an allowlist of renderable actions on the output side is the half nobody writes about, and it is the half that decides what the user can tap.

Defence 3 — grounding, so every reference traces to a tool result

Schema validity is necessary and not sufficient. {"type":"callout","tone":"warning","body":"Your account is suspended. Call 0800…"} is a perfectly valid record. Grounding is the check that catches the rest: before a record is forwarded, every reference in it must resolve to something a tool actually returned during this turn.

In the Uzori gate, validateGrounding rejects a record on five conditions. A subject reference absent from the turn's index; an unknown media reference; a URL that was not present in a tool result; latitude and longitude that were not grounded together; an actionRef outside the handoffs the host declared. An ungrounded URL is not sanitised. The line is rejected.

Four questions that check whether your own pipeline has this property:

  1. Where does a rendered URL come from? If the model can compose one, you have an exfiltration channel, and no amount of output filtering closes it.
  2. What happens to a reference the tool result does not contain? If the answer is "it renders anyway", grounding is absent.
  3. Is the check on the whole record or on the fields you remembered? A field-by-field allowlist rots as the schema grows.
  4. Who else enforces it? A server-side gate the client trusts blindly is one bug away from being no gate.

What this still does not protect you from

None of this stops a model from being wrong inside the contract. A grounded, schema-valid screen can still summarise retrieved data badly, emphasise the wrong figure, or pick the misleading comparison. Structure raises the floor on what an attacker can construct; it does not make the content true.

It also does not clean your data. Grounding binds a rendered reference to a tool result, so a hostile string already sitting in your own database can still be rendered faithfully. It renders as text, in a field sized for text, with no action attached — a much smaller problem than a button, but not nothing.

Mutating verbs are disabled by default, which limits the blast radius on the tool side. That default is overridable per operation: an endpoint reaches the model only if a human both allowlists it and classifies it read. Treat any such promotion as a security change.

And this is a rendering boundary, not a security model for your application. Authentication, session handling, rate limits and what your API returns to whom are all unchanged and all still yours.

How Uzori enforces it

Every model-emitted line passes six checks before it reaches the device: JSON Schema, screen identity, navigation match, stream grammar state, grounding, and cardinality. The gate then forwards the original bytes, so what renders is byte-identical to what passed — there is no re-serialisation step in which something could be reintroduced.

The server never repairs model output. An invalid line is rejected rather than patched or completed; one bounded re-ask is permitted only when the first record fails and nothing has been forwarded to the client. The iOS reducer then re-implements the grammar and its own cardinality limits independently, and the tighter of the two limits wins, so a server bug does not become a rendering bug. Draft content stays non-interactive until the commit record arrives, and an abort restores the screen the user had before the turn. Session state has no credentials field at all, which is enforced by the type rather than by discipline.

Frequently asked questions

Can prompt injection make an app render a fake screen?

Yes, if the renderer accepts whatever tree the model emits. The injected instruction reaches the model through untrusted content, and the model's cooperation produces well-formed records the renderer displays with your app's own styling. The defence is a wire contract narrow enough that a fake button, a fake link or a fake style has no valid field to occupy.

Does sanitising the model's output help if the output is a UI tree?

Not meaningfully. Sanitising removes dangerous characters from strings, and a malicious record contains none — it is ordinary JSON whose danger is in its structure. Validate against a closed schema instead, reject records that fail rather than repairing them, and require every reference in a record to match something a tool returned during that turn.

Is this the same problem as XSS?

They share an ancestor and diverge at the renderer. XSS depends on an interpreter that can be tricked into treating data as code, which is why escaping works in a WebView. A native SwiftUI renderer has no interpreter and no escaping step, so the equivalent risk moves up a level: the tree itself becomes the injection surface, and only schema constraints reduce it.

Does using an on-device model instead of a server model fix it?

No. Where inference runs changes the privacy and latency story, not the trust story — an on-device model reading a poisoned response payload follows the injected instruction exactly as a hosted one would. Apple's own guidance on agentic risk treats untrusted content as untrusted regardless of where the model runs, and puts the controls on actions rather than on location.

Where to start

Open the schema your renderer accepts and read it as an attacker would: for every record type, list the fields that can carry a URL, a label, a colour or arbitrary markup. That list is your real attack surface for indirect prompt injection, and it is usually shorter to fix than to defend.

If you want the version of this that is already enforced — a closed wire contract, host-owned actions, grounding, and a client that re-validates independently — the shipped schema and stream grammar are what we would show you first. Email hello@uzori.ai and ask for them.

← All posts