Home › Security › Application security
algorithm low

JSON Schema validation

Problem Anyone whose service receives JSON from outsiders, such as API clients, webhooks, config files, model tool calls or device messages, needs to reject wrong shapes, missing fields and abusive sizes before business code touches the data.

Compile a JSON Schema once, then check every untrusted document against it, within hard size and depth limits, and report failures as JSON Pointers.

validate O(size of document) for non-recursive schemas; compile O(size of schema), once
json-schemainput-validationtrust-boundaryredosresource-limitsrfc-8259
Overview

Untrusted JSON can be the wrong shape, too big, too deep or ambiguous. A JSON Schema is a declarative contract (types, required members, ranges, lengths, closed objects), compiled once and run at the trust boundary: payment webhooks, patient-record APIs, LLM tool arguments, device telemetry, game client messages. Everything past the boundary then sees data of a known shape.

Key facts

Use when

  • Many producers or languages send the same message shape (public APIs, partner webhooks, config files, tool calls, sensor fleets, client protocols).
  • The shape is data you ship or version, not a fixed Rust struct.

Avoid when

  • One typed deserializer with deny_unknown_fields covers the shape.
  • The rule is semantic (balance, ownership, uniqueness): use the database or authorization.

Deep dive

How it works

In: untrusted JSON bytes + a compiled schema · Out: accept (typed value) or {pointer, keyword} error · State: the compiled schema tree, built once at start-up · Cost: O(instance size) per document, depth-bounded

  1. Cap the body at the transport (RFC 8259 §9 allows limits on size, depth, number range and string length).
  2. Parse. Reject duplicate member names (RFC 8259 §4: unpredictable, many parsers keep the last), invalid UTF-8, excess depth and out-of-range numbers.
  3. At start-up compile the schema: check it against its meta-schema, resolve $ref offline, build a tree, compile regexes with a linear-time engine or step limit.
  4. Validate. Assertions: type, enum, const, minimum/maximum, minLength/maxLength, pattern, required, maxItems, items/prefixItems, additionalProperties, $ref. Annotations (title, default, format by default, content*) never reject.
  5. Stop at the first error, or collect at most N.
  6. Return 400/422 with {pointer, keyword}; log detail privately.
  7. Convert the accepted value into a typed struct.
bytes ─► cap ─► parse (depth, dups, numbers) ─► validate ◄── compiled schema (once)
                                                  │ ok ─► typed value ─► handler
                                                  └ err ─► {pointer, keyword}

Code

Subset validator in std Rust: type, scalar enum, minimum/maximum, maxLength, maxItems, items, required, properties, additionalProperties, $ref: "#" (recursion into the root). Strict compile, depth limit, JSON Pointer errors. The parser (byte cap, depth, duplicates) is not shown; Obj is a Vec so duplicates stay visible to it.

#[derive(Clone, Debug, PartialEq)]
pub enum Json { Null, Bool(bool), Num(f64), Str(String), Arr(Vec<Json>), Obj(Vec<(String, Json)>) }

#[derive(Clone, Copy, PartialEq)] enum Ty { Null, Bool, Int, Num, Str, Arr, Obj }

#[derive(Default)]
pub struct Schema {
    ty: Option<Ty>, choices: Vec<Json>, min: Option<f64>, max: Option<f64>,
    max_len: Option<usize>, max_items: Option<usize>, items: Option<Box<Schema>>,
    props: Vec<(String, Schema)>, required: Vec<String>, closed: bool, recurse: bool,
}
enum Seg<'a> { Key(&'a str), Idx(usize) }
#[derive(Debug, PartialEq)] pub struct Error { pub pointer: String, pub keyword: &'static str }

impl Schema {
    /// Compile once. Strict like Ajv: an unknown or malformed keyword is an error, not an annotation.
    pub fn compile(doc: &Json, depth: usize) -> Result<Schema, String> {
        if depth > 16 { return Err("schema too deep".into()); }
        let Json::Obj(kw) = doc else { return Err("schema must be an object".into()) };
        let mut s = Schema::default();
        for (k, v) in kw {
            match (k.as_str(), v) {
                ("type", Json::Str(t)) => s.ty = Some(match t.as_str() {
                    "null" => Ty::Null, "boolean" => Ty::Bool, "integer" => Ty::Int, "number" => Ty::Num,
                    "string" => Ty::Str, "array" => Ty::Arr, "object" => Ty::Obj,
                    _ => return Err(format!("unknown type {t}")) }),
                ("minimum", Json::Num(n)) => s.min = Some(*n),
                ("maximum", Json::Num(n)) => s.max = Some(*n),
                ("maxLength", Json::Num(n)) => s.max_len = Some(*n as usize),
                ("maxItems", Json::Num(n)) => s.max_items = Some(*n as usize),
                ("enum", Json::Arr(a)) if a.iter().all(|x| !matches!(x, Json::Arr(_) | Json::Obj(_))) => s.choices = a.clone(),
                ("required", Json::Arr(a)) => for x in a {
                    let Json::Str(name) = x else { return Err("required wants strings".into()) };
                    s.required.push(name.clone());
                },
                ("properties", Json::Obj(p)) => for (name, sub) in p {
                    s.props.push((name.clone(), Schema::compile(sub, depth + 1)?));
                },
                ("additionalProperties", Json::Bool(b)) => s.closed = !*b,
                ("items", sub) => s.items = Some(Box::new(Schema::compile(sub, depth + 1)?)),
                ("$ref", Json::Str(r)) if r == "#" => s.recurse = true, // only "recurse into the root"
                _ => return Err(format!("unsupported or malformed keyword {k}")),
            }
        }
        Ok(s)
    }

    pub fn validate(&self, v: &Json, max_depth: usize) -> Result<(), Error> {
        let mut path = Vec::new();
        self.check(self, v, &mut path, 0, max_depth).map_err(|keyword| {
            let mut pointer = String::new(); // built only on failure
            for seg in &path {
                match seg {
                    Seg::Key(k) => { pointer.push('/'); pointer += &k.replace('~', "~0").replace('/', "~1"); }
                    Seg::Idx(i) => pointer += &format!("/{i}"),
                }
            }
            Error { pointer, keyword }
        })
    }

    // On Err the path is left pointing at the failing location (no pop after `?`).
    fn check<'a>(&self, root: &Schema, v: &'a Json, path: &mut Vec<Seg<'a>>, d: usize, lim: usize) -> Result<(), &'static str> {
        if d > lim { return Err("depth"); }
        if self.recurse { root.check(root, v, path, d + 1, lim)?; }
        if let Some(t) = self.ty {
            let ok = match (t, v) {
                (Ty::Null, Json::Null) | (Ty::Bool, Json::Bool(_)) | (Ty::Num, Json::Num(_))
                | (Ty::Str, Json::Str(_)) | (Ty::Arr, Json::Arr(_)) | (Ty::Obj, Json::Obj(_)) => true,
                (Ty::Int, Json::Num(n)) => n.is_finite() && n.fract() == 0.0,
                _ => false,
            };
            if !ok { return Err("type"); }
        }
        if !self.choices.is_empty() && !self.choices.contains(v) { return Err("enum"); }
        match v {
            Json::Num(n) => {
                if self.min.is_some_and(|m| *n < m) { return Err("minimum"); }
                if self.max.is_some_and(|m| *n > m) { return Err("maximum"); }            }
            // bytes >= chars, so counting is needed only when the byte length already exceeds the limit
            Json::Str(s) => if self.max_len.is_some_and(|m| s.len() > m && s.chars().count() > m) { return Err("maxLength") },
            Json::Arr(a) => {
                if self.max_items.is_some_and(|m| a.len() > m) { return Err("maxItems"); }
                if let Some(item) = &self.items {
                    for (i, x) in a.iter().enumerate() {
                        path.push(Seg::Idx(i));
                        item.check(root, x, path, d + 1, lim)?;
                        path.pop();
                    }
                }
            }
            Json::Obj(m) => {
                if self.required.iter().any(|r| !m.iter().any(|(k, _)| k == r)) { return Err("required"); }
                for (k, x) in m {
                    path.push(Seg::Key(k));
                    match self.props.iter().find(|(n, _)| n == k) {
                        Some((_, sub)) => sub.check(root, x, path, d + 1, lim)?,
                        None if self.closed => return Err("additionalProperties"),
                        None => {}
                    }
                    path.pop();
                }
            }
            _ => {}
        }
        Ok(())
    }
}
#[cfg(test)] mod t { use super::{Json::*, *};
    fn s(x: &str) -> Json { Str(x.into()) }
    fn o(v: Vec<(&str, Json)>) -> Json { Obj(v.into_iter().map(|(k, x)| (k.into(), x)).collect()) }
    #[test] fn basics() {
        let id = o(vec![("type", s("integer")), ("minimum", Num(1.0))]);
        let doc = o(vec![("additionalProperties", Bool(false)), ("properties", o(vec![("id", id)]))]);
        let sc = Schema::compile(&doc, 0).unwrap();
        assert_eq!(sc.validate(&o(vec![("id", Num(2.0))]), 8), Ok(()));
        assert_eq!(sc.validate(&o(vec![("id", Num(0.0))]), 8), Err(Error { pointer: "/id".into(), keyword: "minimum" }));
        assert_eq!(sc.validate(&o(vec![("a/b", Null)]), 8).unwrap_err().pointer, "/a~1b");
    }
}

Compiled and run with a larger assert suite (every keyword, UTF-8 maxLength, schema typo rejected, a 100-deep tree stopped at limit 64, a self-looping {"$ref":"#"} stopped by the limit): all passed.

Complexity and constants

  • O(nodes visited); each object member scans the schema's properties linearly (hash or sort beyond a few dozen).
  • maxLength counts code points (RFC 8259 characters), exact check O(bytes); the byte-length shortcut makes the common pass O(1).
  • uniqueItems is quadratic without hashing; pattern is the only keyword with exponential potential.
  • Cost drivers: allocations (path stack, error strings), regex steps, bytes parsed. No syscalls or fsync: pure CPU.

Efficiency

Cost: measured here (Apple M4 Pro, release build, one thread, 2M runs, document with 4 members and a 2-item array): about 70 ns per valid document and about 145 ns for a document that fails at its tail, including building the pointer string. Allocation-bound; parsing the bytes costs more than validating.

Levers:

  • Compile once and reuse (jsonschema README: "Build & reuse (faster)") — recompiling per request is the common waste — always.
  • Generate the validator at build time — about 2.3-10x faster than the runtime validator on its benchmark schemas (jsonschema README, macros feature); Ajv also generates code — fixed schemas.
  • Fail fast — bounds work per hostile document (Ajv's security page: no allErrors in production) — public endpoints.
  • Byte-length before character count — skips scanning long strings (design, this page's code) — string-heavy messages.
  • Linear-time regex engine (RE2 in Ajv via code.regExp; the regex crate in jsonschema) — removes ReDoS at the cost of look-ahead and back-references (Ajv docs) — any pattern.

Naive → standard → state of the art:

  • Parse the schema again and walk it as JSON for every request · extra O(schema) per document.
  • Compile to a tree once, fail fast, limits at the parser · about 70 ns here.
  • Schema compiled to code at build time, typed decode of the accepted value · fastest (jsonschema macros, Ajv standalone).

Implementation notes

  • Limits where cost starts: body cap in the HTTP layer, depth in the parser (serde_json stops at depth 128; only the unbounded_depth feature plus disable_recursion_limit lifts it), maxItems and maxLength on every array and string.
  • Numbers: RFC 8259 §6 calls integers within ±(2^53-1) interoperable as doubles; JSON Schema sets no bound (validation spec §4.2). Send 64-bit ids as strings or parse integers without f64.
  • Closed objects: additionalProperties sees only the properties of its own schema object; across allOf use unevaluatedProperties (core §11.3).
  • Errors: pointer plus keyword. Attacker-chosen member names appear in pointers; cap their length when logging.
  • Never compile attacker-supplied schemas: Ajv treats schemas as trusted as application code; jsonschema resolves HTTP and file $ref by default.

Pitfalls

  • Trusting format: email: an annotation by default in 2020-12; the Format-Assertion vocabulary is optional and formats may be no-ops. Check your library's switch.
  • Keyword typos pass as annotations. Compile strictly (Ajv strict, this page's compiler).
  • pattern is not anchored (validation spec §6.3.3): [a-z]+ matches "1a1". Write ^[a-z]+$.
  • (a+)+$ on aaaa…! backtracks exponentially in backtracking engines; validation spec §10 warns of it. Add maxLength, but it does not always save you (Ajv).
  • {} and true accept everything; a $ref cycle must not loop (core §13: MUST NOT).
  • Parsers disagree on duplicate keys, so a proxy and the service can see different documents. Reject duplicates.
  • maxLength counts code points, not bytes or grapheme clusters; see Unicode confusables for look-alike names.
  • integer accepts 1.0 (zero fractional part).

Variants and alternatives

Typed decode (serde with deny_unknown_fields, off by default for JSON)one struct per message, no second language.
JSON Type Definition (RFC 8927; Ajv supports it)smaller, regular, easy to code-generate.
OpenAPI 3.1its Schema Object is a superset of 2020-12: one document for docs and validation.
Binary schemas (protobuf wire format, binary JSON)shape lives in the codec.Streaming validation (JSON streaming parser)bodies too big to hold.

In production

  • Tauri validates its config and Apollo Router its configuration with the Rust jsonschema crate (its README lists both).
  • OpenAPI 3.1 request validation in API gateways (finance, retail and telecom APIs).
  • Tool calling: tool definitions carry a JSON Schema (input_schema in Anthropic's tool-use API); the model's arguments are still untrusted input.
  • Healthcare: the jsonschema crate benchmarks on the FHIR schema.
  • Game servers and IoT brokers apply the same pattern to client messages, often with typed decoders.

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
jsonschemaRustStranger6667/jsonschemacargo add jsonschema————validator_for builds a reusable validator; with_pattern_options (fancy-regex default, backtrack_limit); build-time macro; HTTP/file $ref resolution on by default
boonRustsanthosh-tekuri/booncargo add boon————drafts 4 to 2020-12; format assertions behind a flag for 2019-09 and later; ECMA-262 regex
AjvJS/TSajv-validator/ajvnpm install ajv————compiles schemas to JS; strict mode; formats need ajv-formats; RE2 hook
jsonschemaPythonpython-jsonschema/jsonschemapip install jsonschema————format checking only with a format_checker; validate() checks the schema first

Sources

  • Wright, Andrews, Hutton, Dennis, JSON Schema: A Media Type for Describing JSON Documents, Internet-Draft, draft 2020-12 (core), June 2022. spec
  • Wright, Andrews, Hutton, JSON Schema Validation: A Vocabulary for Structural Validation of JSON, Internet-Draft, draft 2020-12, June 2022. spec
  • T. Bray (ed.), RFC 8259: The JavaScript Object Notation (JSON) Data Interchange Format, 2017. RFC
  • Bryan, Zyp, Nottingham, RFC 6901: JavaScript Object Notation (JSON) Pointer, 2013. RFC
  • Ajv, Security considerations and Strict mode. security · strict
  • jsonschema crate docs, ValidationOptions. docs
  • serde, Container attributes (deny_unknown_fields). doc · serde_json

Used by

Constrained decodingCode that consumes LLM output (tool-call arguments, JSON, SQL, classification labels) breaks when the model emits malformed text, especially with small models. This is about guaranteeing the output matches a required format at generation time.
event schema normalization · plannedMapping heterogeneous log/event sources into one schema (ECS, OCSF) at ingest
Infrastructure as codeTeams that create servers and cloud resources by clicking in a console cannot reproduce environments, review changes or recover quickly from disaster. This guide covers managing infrastructure as reviewed files and its risks.Input validation and injectionAnyone handling data from requests, files, queues, webhooks or LLM output needs to avoid it being interpreted as code in SQL, shells, HTML, paths or templates. This guide answers how to treat untrusted input safely.
model context protocol · plannedJSON-RPC protocol exposing tools, resources and prompts from servers to LLM clients, with capability negotiation
schema evolution · plannedCompatibility rules (backward/forward/full) for adding, removing and renaming fields
scim provisioning · plannedSCIM 2.0 user/group provisioning API: create, patch, deprovision, filters
Tool callingAn LLM can only produce text, yet an agent must search, read files, query databases or call APIs. This is how a model requests actions from your code and gets the results back safely.Tool retrieval (selecting tools for a request)An agent has far more tools, actions or skills than fit in its prompt or than the model can choose among reliably. Before each model call something must pick the few that matter for this request and this caller.

Systems (26):

Alternatives and related