research/fuzzing-an-llm-json-extractor

Fuzzing an LLM JSON Extractor: What Breaks When You Parse Between the First and Last Brace

Engineering Note · October 28, 2026 · 9 minute read · AI integration · Testing · Validation

An LLM JSON extractor is the small function that pulls a JSON object out of a model reply that may also contain prose, code fences or damage. The most common version slices from the first { to the last } and calls JSON.parse in a try/catch. We fuzzed that pattern with 99 deterministic malformed replies. It rejected most of them correctly, but accepted 9 wrongly, and a smarter parser did not fix that on its own. What fixed it was strict validation, an ambiguity rule, and a fallback.

Everything ran on Node v22.22.2 with no dependencies. The corpus is seeded, so the numbers reproduce exactly.

What is the common extractor, and what does it return?

Extractor A is the one in most tutorials, and the one we used in an earlier note on structured JSON and validation:

export function extractA(text) {
  const start = text.indexOf("{"), end = text.lastIndexOf("}");
  if (start === -1 || end <= start) return null;
  try { return JSON.parse(text.slice(start, end + 1)); } catch { return null; }
}

The schema is tiny, {question: string | null}, so every outcome is easy to judge. The strict validator builds a new object from the one known key and rejects everything else. The lax validator is the shortcut people write when they want a result no matter what:

export function validate(obj) {
  if (!obj || typeof obj !== "object" || Array.isArray(obj)) return null;
  if (!Object.hasOwn(obj, "question")) return null;
  const q = obj.question;
  if (q === null) return { question: null };
  if (typeof q !== "string") return null;
  const t = q.trim();
  if (t.length === 0 || t.length > 300 || !t.endsWith("?")) return null;
  return { question: t };
}
export const validateLax = (obj) =>
  obj && typeof obj === "object"
    ? { question: typeof obj.question === "string" ? obj.question : null } : null;

How do you build a fuzz corpus for model output?

We wrote 33 classes of realistic bad replies, three seeded variants each, for 99 cases. Each carries an expected outcome: the value a careful reader would extract, or reject. Our policy: wrapper noise (fences, prose, BOM) is tolerated; malformed JSON, ambiguous replies and wrong shapes must be rejected.

function mulberry32(a) { /* 4-line seeded PRNG, fixed seed */ }
const j = (q) => JSON.stringify({ question: q });
const E = (q) => ({ kind: "accept", value: { question: q } });
const R = { kind: "reject" };
const classes = [
  ["fenced ```json",       () => { const q = pick(Q); return ["```json\n" + j(q) + "\n```", E(q)]; }],
  ["two objects, 2nd cut", () => { const a = pick(Q); return [j(a) + '\n{"question": "Which pag', R]; }],
  ["zero-width in value",  () => { const q = pick(Q); return [j(q.slice(0, 4) + "\u200b" + q.slice(4)), E(q)]; }],
  ["array of one",         () => { const q = pick(Q); return ["[" + j(q) + "]", R]; }],
  // ...29 more: trailing comma, single quotes, unquoted keys, truncation, smart quotes,
  // BOM, two objects, nested wrapper, extra keys, wrong types, empty input, prose only
];

Grading has three outcomes. ok means the right call. safe means we rejected something we could have accepted, which costs one skipped suggestion. DANGER means we accepted data we should not have, or accepted the wrong value, which puts bad data in the product.

How does the first-to-last-brace extractor do?

cases: 99  classes: 33  expect-accept: 39  expect-reject: 60

pipeline  ok  safe  DANGER
A          84     6       9
B          81     3      15
C          99     0       0
A+lax      60     6      33

B is the balanced scanner below, C is the recommended helper, and A+lax is extractor A with the lax validator. Extractor A scored 84 ok, 6 safe and 9 dangerous. Most malformed JSON failed in the loud way: trailing commas, single quotes, unquoted keys, smart-quote delimiters, truncation mid-string and empty input all landed in the reject path. Running node run.mjs --classes prints the per-class breakdown. These are the classes where an extractor stumbled:

Case classA: first to lastB: balanced scannerFailure type
braces in prose0/3, safe3/3A slices across prose braces, parse fails, valid reply lost
two objects3/30/3, dangerB silently keeps the first object
two objects, 2nd cut0/3, danger0/3, dangerTruncated tail is ignored, first object accepted
zero-width in value0/3, danger0/3, dangerInvisible character stored in the question
zero-width in syntax0/3, safe0/3, safeJSON.parse rejects U+200B between tokens
array of one0/3, danger0/3, dangerSlicing unwraps the array, contract violated
array of two3/30/3, dangerB picks the first element of the array

The other 26 classes were 3/3 for both extractors, including every fence, BOM, nested-key and wrong-type case. The parser only had to get text into an object. The strict validator decided whether the object was acceptable.

What does the balanced-brace scanner change?

Extractor B walks the text once, tracking string state and backslash escapes, and collects every balanced top-level {...} span. It then parses candidates in order and returns the first object:

export function extractB(text) {
  for (const c of candidates(text)) {
    try { const v = JSON.parse(c); if (v && typeof v === "object") return v; } catch {}
  }
  return null;
}

candidates() is the same scan loop that appears as objects() in the final helper below, minus the truncation flag. B fixed the prose-braces class: 3 safe rejections became 3 correct accepts, and total safe rejections dropped from 6 to 3. But total dangerous accepts rose from 9 to 15. A was protected in “two objects” and “array of two” by accident: slicing across two objects produces invalid JSON, so JSON.parse threw. B is lenient on purpose, so it picks one and moves on. Greater parsing power turned a loud failure into a silent one.

Which failures are dangerous, and which are safe?

A rejection is cheap: the product shows its non-AI fallback and nothing wrong enters the system. Every dangerous case above is a place where the parser decided to be helpful:

  • Ambiguity resolved by guessing. Two objects, or a truncated second object, means the model changed its mind or got cut off. Picking the first one is a coin flip presented as data.
  • Shape quietly changed. An array of one object is not the contract. Unwrapping it works until the day the array has a different first element.
  • Invisible characters. A zero-width space inside a question passes every type check and then shows up in logs, comparisons and search.

The lax validator shows what happens when validation gives way too. Paired with extractor A it scored 60 ok, 6 safe and 33 dangerous. Wrong key names, empty objects, numbers, arrays and wrapped objects all became {question: null}, and if null means “nothing left to ask,” garbage silently ends the conversation. Same parser as A with strict validation, 9 dangerous; with lax, 33. The validator moved the count by 24 and the parser choice moved it by 6.

Extractor C is B plus three rules we added after reading these results: strip invisible characters, reject a reply that starts with an array or leaves an object unterminated, and accept only when exactly one object parses. We folded it and the strict validator into one function that returns an object or null, where null always means use the fallback:

const INVISIBLE = /[\u200b-\u200d\u2060\ufeff]/g;

function objects(text) {              // balanced top-level {...} spans, string- and escape-aware
  const out = []; let depth = 0, start = -1, inStr = false, esc = false;
  for (let i = 0; i < text.length; i++) {
    const c = text[i];
    if (inStr) { esc ? (esc = false) : c === "\\" ? (esc = true) : c === '"' && (inStr = false); continue; }
    if (c === '"' && depth > 0) inStr = true;
    else if (c === "{") { if (depth++ === 0) start = i; }
    else if (c === "}" && depth > 0 && --depth === 0) out.push(text.slice(start, i + 1));
  }
  return depth > 0 ? null : out;      // null = an object was left open (truncated)
}

export function parseQuestion(reply) {
  const text = String(reply ?? "").replace(INVISIBLE, "");
  if (/^\s*(```(json)?\s*)?\[/.test(text)) return null;       // top-level array
  const spans = objects(text);
  if (!spans) return null;
  const parsed = [];
  for (const s of spans) { try { const v = JSON.parse(s); if (v && typeof v === "object") parsed.push(v); } catch {} }
  if (parsed.length !== 1) return null;                       // none, or ambiguous
  const o = parsed[0];
  if (!Object.hasOwn(o, "question")) return null;
  if (o.question === null) return { question: null };
  if (typeof o.question !== "string") return null;
  const q = o.question.trim();
  return q && q.length <= 300 && q.endsWith("?") ? { question: q } : null;
}

On the same corpus, the helper matched the expected outcome in 99 of 99 cases and agreed with the C pipeline on 99 of 99. Four cases side by side:

[zero-width in value]
  input : "{\"question\":\"What<ZWSP> is your launch deadline?\"}"
  A     : {"question":"What<ZWSP> is your launch deadline?"}
  B     : {"question":"What<ZWSP> is your launch deadline?"}
  helper: {"question":"What is your launch deadline?"}
[array of one]
  input : "[{\"question\":\"What is your launch deadline?\"}]"
  A     : {"question":"What is your launch deadline?"}
  B     : {"question":"What is your launch deadline?"}
  helper: null
[two objects, 2nd cut]
  input : "{\"question\":\"What is the budget range for this project?\"}\n{\"question\": \"Which pag"
  A     : {"question":"What is the budget range for this project?"}
  B     : {"question":"What is the budget range for this project?"}
  helper: null
[braces in prose]
  input : "Use the {name} placeholder. {\"question\":\"Who is the primary audience for the site?\"}"
  A     : null
  B     : {"question":"Who is the primary audience for the site?"}
  helper: {"question":"Who is the primary audience for the site?"}

What this does not catch

The 99 of 99 needs a caveat, because we wrote C after reading the failures. It is fitted to this corpus.

  • Our corpus is not real traffic. We invented 33 classes from experience. Real model replies have shapes we did not think of, and classes here are weighted equally, not by how often they occur. Log rejected replies in production and add each new shape as a fuzz case.
  • Semantic errors pass every check. A well-formed question that ends in ? but asks about the wrong project is accepted by every pipeline here. Validation checks structure, not truth.
  • Untested inputs. Duplicate keys, very large replies, deeply nested objects and parser timing were not measured.
  • Stripping invisible characters is a choice. U+200D joins some emoji sequences. Fine for a plain-text question; strip selectively where emoji matter.
  • Rejection has a cost. Some valid-looking answers will fall back. The fallback has to be good enough that nobody notices.

A clever parser mostly relocates failures. Strict validation, an ambiguity rule and a working fallback remove them.

Frequently asked questions

How do you extract JSON from an LLM response reliably?

Find every balanced top-level object while respecting string literals and escapes, parse each one, and accept the reply only when exactly one object parses and passes schema validation. Anything else, including zero objects, several objects, or a truncated tail, goes to a fallback. Reliability comes from rejecting ambiguity, not from a cleverer parser.

Is slicing from the first brace to the last brace safe?

Mostly, because malformed text usually fails JSON.parse and gets rejected. In our 99-case fuzz run it still silently accepted 9 replies it should have refused: a truncated second object, a one-element array, and invisible zero-width characters inside a value. It also wrongly rejected valid replies that had braces in the surrounding prose.

Why does a balanced-brace scanner need to track strings and escapes?

Braces can appear inside string values, as in a question about a {placeholder} or a literal closing brace. A scanner that counts every brace would close the object early or never close it. Tracking whether you are inside a string, and whether the previous character was a backslash, keeps the depth count correct.

Should you repair malformed JSON from a model?

We would not. Repairing trailing commas, quotes or truncation means your code authors content the model never produced. Reject the reply and use the non-AI fallback. Our fuzz corpus included trailing commas, single quotes, unquoted keys and truncation, and discarding all of them was safe and cost one skipped suggestion each.

Does structured output mode remove the need for validation?

No. Treat the reply as untrusted input whatever the provider promises. Output can still be truncated, refused, or valid JSON of the wrong shape, and a lax validator that coerces bad values to null accepted 33 of our 99 cases wrongly. Validate every field and keep a fallback path.

More from Research

← All research