JSONPath is now standardized (RFC 9535), and more teams are using JSON Pointer for stable references and JSON Patch for "efficient updates." But every time you accept expressive strings that influence evaluation, you've created a mini interpreter in your stack. Here's how to do it safely.
TL;DR
- Treat JSONPath/Pointer/Patch strings as query languages—they need allowlists and budgets
- For PATCH endpoints: apply patch → validate full resource → authorize → persist
- The most common bypass: validating only the patch document, not the resulting state
- If you must accept user-defined JSONPath, restrict the grammar and cap runtime
The Primitives (Quick Refresher)
JSONPath (RFC 9535)
JSONPath selects nodes from a JSON document. It can include field selection, array selection, and filters. With RFC 9535, we finally have standardized semantics.
$.store.book[*].author # All authors
$.store.book[?@.price<10] # Books under $10
$..author # All authors (recursive) JSON Pointer (RFC 6901)
JSON Pointer is a string syntax to reference a specific location in a JSON document:
/a/b/0 # obj.a.b[0]
/foo~1bar # key "foo/bar" (escaped)
/foo~0bar # key "foo~bar" (escaped) JSON Patch (RFC 6902)
JSON Patch is an array of operations: add, remove, replace,
move, copy, test.
[
{"op": "replace", "path": "/name", "value": "Alice"},
{"op": "add", "path": "/tags/-", "value": "new-tag"},
{"op": "remove", "path": "/deprecated"}
] JSON Merge Patch (RFC 7386)
Merge Patch is a partial document: keys set to null mean delete; other keys replace.
{
"name": "Alice",
"deprecated": null
} Threat Model: What Attackers Do
1. Path Injection to Update Unauthorized Fields
If a client can send:
[
{"op": "replace", "path": "/role", "value": "admin"}
]
Your API must ensure the caller is authorized to change /role.
Type validation alone doesn't prevent privilege escalation.
2. "Patch-Only Validation" (The Common Bug)
Teams validate the patch document (it's an array of ops) but never validate the resulting resource. Attackers craft valid ops that produce an invalid state.
3. DoS via Expensive Selectors
User-defined JSONPath can cause:
- Deep traversal (recursive descent)
- Large fan-out selections
- Heavy filter evaluation
4. Data Exfil via Filter/Query Languages
If JSONPath controls which fields are returned, attackers can request sensitive fields by selecting them.
Safe Patterns for JSONPath
Pattern A: Don't Accept Arbitrary JSONPath from Untrusted Users
Instead, offer:
- Fixed query parameters (
?status=active&createdAfter=...) - Pre-defined query IDs (
queryId=...) - A constrained DSL you control
If you must accept JSONPath, treat it like SQL: parse, validate against an allowlist grammar, and cap runtime.
Pattern B: Restrict JSONPath to "Read-Only Selectors"
Restrict to:
- Root
$ - Simple dot/bracket name selection
- Numeric array indices
- No filters, no scripts, no recursive descent
And cap:
- Maximum path length
- Maximum selected nodes
- Maximum document size
const JSONPATH_LIMITS = {
maxPathLength: 100,
maxSelectedNodes: 100,
maxDocumentSizeBytes: 1_000_000,
allowedFeatures: {
recursiveDescent: false,
filters: false,
scripts: false,
},
};
function validateJSONPath(path: string): boolean {
if (path.length > JSONPATH_LIMITS.maxPathLength) return false;
if (path.includes('..')) return false; // recursive descent
if (path.includes('?')) return false; // filters
if (path.includes('(')) return false; // scripts
return true;
} Safe Patterns for PATCH Endpoints
The Gold Standard: Apply → Validate → Authorize → Persist
- Load current resource (R0)
- Apply patch to produce candidate (R1)
- Validate R1 against the same schema used for full PUT/POST
- Authorize the transition (who can change what)
- Persist with optimistic concurrency
async function handlePatch(resourceId: string, patch: JsonPatch, user: User) {
// 1. Load current resource
const current = await db.getResource(resourceId);
if (!current) throw new NotFoundError();
// 2. Apply patch to produce candidate
const candidate = applyPatch(current, patch);
// 3. Validate full result (not just the patch!)
const validation = schema.validate(candidate);
if (!validation.valid) {
throw new ValidationError(validation.errors);
}
// 4. Authorize the transition
const changedPaths = getChangedPaths(current, candidate);
for (const path of changedPaths) {
if (!canUserModifyPath(user, path)) {
throw new ForbiddenError(`Cannot modify ${path}`);
}
}
// 5. Persist with optimistic concurrency
await db.updateResource(resourceId, candidate, current.version);
} This prevents:
- Partial-update bypasses
- Schema drift between PUT and PATCH
- "Hidden field" injection
Field-Level Authorization: Allowlist Mutable Paths
Build a server-side allowlist:
const PATH_PERMISSIONS = {
user: ['/displayName', '/profile/bio', '/settings/*'],
admin: ['/displayName', '/profile/bio', '/settings/*', '/role', '/status'],
};
function canUserModifyPath(user: User, path: string): boolean {
const allowedPaths = PATH_PERMISSIONS[user.role] || [];
return allowedPaths.some(allowed => {
if (allowed.endsWith('/*')) {
return path.startsWith(allowed.slice(0, -1));
}
return path === allowed;
});
} Require test Operations for Risky Mutations
For high-concurrency or sensitive resources, require a test op before a replace:
[
{"op": "test", "path": "/version", "value": "v12"},
{"op": "replace", "path": "/displayName", "value": "New Name"}
] Or enforce HTTP If-Match with ETags instead (often simpler).
JSON Pointer Sharp Edges
Escaping Bugs Become Security Bugs
Because / and ~ must be escaped, naive string splitting can:
- Target the wrong field
- Bypass allowlists if you compare unescaped vs escaped forms inconsistently
JSON Patch Sharp Edges
Array Operations: - and Index Handling
path: "/items/-" means append (add). Ensure your allowlist recognizes this.
Also budget array sizes: an attacker can append 10k elements if you don't cap.
move / copy Can Bypass Naive Allowlists
If you allow writing to /public, but not reading from /secret,
copy can exfiltrate within the document:
{"op": "copy", "from": "/secret", "path": "/public"} - Treat
fromas sensitive too - Allowlist operations per endpoint (
add/replaceonly is common) - Or prohibit
move/copyentirely for untrusted clients
Merge Patch Sharp Edges
Merge Patch is deceptively simple:
- Setting a key to
nulldeletes it - Objects merge recursively, other values replace
Pitfall: A patch can delete security-relevant fields (e.g., mfaEnabled: null)
unless you authorize deletions.
- Validate full resource after merge
- Enforce required fields
- Enforce "cannot delete" policy for certain paths
Implementation Checklist
- ☐ If JSONPath comes from users, treat it like SQL: allowlist grammar + runtime budgets
- ☐ For PATCH: apply patch then validate full resulting resource
- ☐ Enforce field-level authorization on paths (
pathandfrom) - ☐ Disable or strictly control
move/copyoperations - ☐ Normalize JSON Pointers before comparing to allowlists
- ☐ Enforce input budgets: patch length, op count, max path length, max array growth
- ☐ Use ETags /
If-Matchto avoid lost updates and reduce race bugs
References
- RFC 9535 — JSONPath: Query Expressions for JSON
- RFC 6901 — JavaScript Object Notation (JSON) Pointer
- RFC 6902 — JavaScript Object Notation (JSON) Patch
- RFC 7386 — JSON Merge Patch
Continue Learning
- JSONPath Tutorial — Learn the query syntax
- Schema-First Security — Use schemas as a security control
- Working with JSON APIs — Best practices for API design
- JSON Tools — Validate and format JSON online