Rewriting JSON Payloads on the Fly
Mutating a request or response body at the proxy layer sounds straightforward until a 47 KB upstream payload silently truncates a client response, or a schema migration causes an upstream service to return 400 Bad Request on every call — with no clue that the gateway changed the Content-Length header but not its value. Body rewriting is the highest-risk operation in the request and response transformation pipeline: unlike header injection, it changes the byte stream the HTTP stack is already accounting for, which means every mutation must be atomically correct or it corrupts the connection.
Prerequisites
This page assumes familiarity with how a middleware chain intercepts traffic, and specifically with the request and response transformation stage where body mutation sits. Before implementing any of the patterns below, confirm:
- Your gateway supports explicit body buffer hooks (
body_filter,accessphase, or a Wasm body interceptor). - You have set
client_max_body_size(NGINX/OpenResty) or equivalent buffer limits on the gateway, sized to the largest payload you expect to mutate. - Auth and routing decisions are completed before body mutation fires on requests — mutation should never precede authentication and token validation.
Body Rewriting Architecture: Buffering vs Streaming
The two execution models for JSON mutation have fundamentally different resource profiles. The diagram below shows where each model sits in the proxy lifecycle.
Full buffering accumulates every chunk in worker memory, then deserialises, mutates, and re-serialises. It is predictable and straightforward but memory-intensive: a 10 MB payload allocates roughly 30–50 MB of working set once parsing overhead is included.
Streaming / chunked processing suppresses output on each intermediate chunk, accumulates them in a per-request context object, and only parses at EOF. Despite the name, this is still technically buffering — the difference is that the gateway does not hold up the upstream connection waiting for the full body before it starts receiving. For very large payloads or partial-field mutations via a streaming JSON library (yajl, simdjson), true incremental processing is possible, but that requires stateful chunk parsing that almost no gateway plugin exposes natively.
Middleware positioning: JSON mutation of request bodies must run after TLS termination and auth verification, and before upstream routing. Response body mutation runs after the upstream returns data and before the response reaches the client. This matches the lifecycle described in the parent request and response transformation guide.
Step-by-Step Configuration by Gateway
Kong 3.x — Custom Plugin
Kong executes plugin phases sequentially. Request body mutation belongs in access; response mutation belongs in body_filter. Both phases share the same Lua VM, so cjson is always available.
-- kong-plugins/json-rewriter/handler.lua
-- Tested on Kong 3.x (Kong Gateway 3.6+)
local cjson = require "cjson"
local JsonRewriter = {}
JsonRewriter.PRIORITY = 1000
JsonRewriter.VERSION = "1.0.0"
-- ── Request mutation (access phase) ──────────────────────────────────────────
function JsonRewriter:access(conf)
local ct = kong.request.get_header("Content-Type") or ""
if not ct:find("application/json", 1, true) then return end
local body, err = kong.request.get_raw_body()
if not body or body == "" then return end
local ok, data = pcall(cjson.decode, body)
if not ok then
-- Never block on parse failure — pass through and let upstream decide
kong.log.warn("json-rewriter: request parse failed: ", data)
return
end
-- ── Your mutation logic here ──────────────────────────────────────────────
data._gateway = {
transform_version = conf.schema_version, -- e.g. "v2"
processed_at = ngx.time(),
}
-- Remove deprecated field before it reaches upstream
data.legacy_user_id = nil
-- ─────────────────────────────────────────────────────────────────────────
local new_body = cjson.encode(data)
kong.service.request.set_raw_body(new_body)
-- Exact byte length — do NOT rely on implicit recalculation
kong.service.request.set_header("Content-Length", tostring(#new_body))
end
-- ── Response mutation (body_filter phase) ────────────────────────────────────
function JsonRewriter:body_filter(conf)
local ct = kong.response.get_header("Content-Type") or ""
if not ct:find("application/json", 1, true) then return end
local chunk, eof = ngx.arg[1], ngx.arg[2]
local ctx = kong.ctx.plugin
ctx.buf = (ctx.buf or "") .. (chunk or "")
if not eof then
ngx.arg[1] = "" -- suppress intermediate chunks
return
end
local ok, data = pcall(cjson.decode, ctx.buf)
if not ok then
ngx.arg[1] = ctx.buf -- pass through on failure
return
end
-- Strip internal fields clients must not see
data._internal_trace_id = nil
local new_body = cjson.encode(data)
-- Content-Length is already cleared by Kong when body_filter mutates output;
-- set it explicitly to be safe with HTTP/1.1 keep-alive clients.
kong.response.set_header("Content-Length", tostring(#new_body))
ngx.arg[1] = new_body
end
return JsonRewriter
# kong.yml declarative — attach the plugin to a specific route
plugins:
- name: json-rewriter
route: my-api-route
config:
schema_version: "v2"
Envoy 1.32+ — Lua HTTP Filter
Envoy’s Lua filter runs synchronously inside the HTTP filter chain. envoy_on_request handles the request path; envoy_on_response handles responses. Both callbacks block their respective processing threads until they return, so keep mutation logic fast — or offload to an external authorisation server for complex transforms.
# envoy.yaml — HTTP filter chain entry (Envoy 1.32+)
http_filters:
- name: envoy.filters.http.lua
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.LuaPerRoute
source_code:
inline_string: |
local json = require("cjson")
function envoy_on_request(request_handle)
local ct = request_handle:headers():get("content-type") or ""
if not ct:find("application/json") then return end
local body_obj = request_handle:body()
local raw = body_obj:getBytes(0, body_obj:length())
local ok, data = pcall(json.decode, raw)
if not ok then return end -- pass through on parse failure
data._gateway_schema = "v2"
data.legacy_user_id = nil -- remove deprecated field
local new_raw = json.encode(data)
body_obj:setBytes(new_raw)
request_handle:headers():replace("content-length", tostring(#new_raw))
end
function envoy_on_response(response_handle)
local ct = response_handle:headers():get("content-type") or ""
if not ct:find("application/json") then return end
local body_obj = response_handle:body()
local raw = body_obj:getBytes(0, body_obj:length())
local ok, data = pcall(json.decode, raw)
if not ok then return end
-- Add envelope metadata
data.meta = { api_version = "v2", served_by = "gateway" }
data._internal_trace_id = nil
local new_raw = json.encode(data)
body_obj:setBytes(new_raw)
response_handle:headers():replace("content-length", tostring(#new_raw))
end
Envoy note:
request_handle:body()blocks until the full body is buffered. Configureper_route_config→max_request_bytesto set an upper bound and avoid unbounded memory growth on large uploads.
NGINX / OpenResty — body_filter_by_lua_block
NGINX processes response bodies in chunks via body_filter_by_lua_block. The callback fires once per chunk, not once per response, so accumulation across invocations is mandatory.
# nginx.conf — response body mutation via OpenResty (ngx_lua 0.10.x+)
location /api/v2/ {
proxy_pass http://upstream_backend;
# Invalidate Content-Length before chunks arrive so we can send a
# different-sized body without breaking keep-alive accounting.
header_filter_by_lua_block {
ngx.header.content_length = nil
ngx.ctx.buf = {}
}
body_filter_by_lua_block {
local chunk = ngx.arg[1]
local eof = ngx.arg[2]
-- Accumulate every non-empty chunk
if chunk and chunk ~= "" then
ngx.ctx.buf[#ngx.ctx.buf + 1] = chunk
end
if not eof then
-- Suppress this chunk; we will emit the full body at EOF
ngx.arg[1] = ""
return
end
-- All chunks received — parse, mutate, emit
local json = require "cjson"
local body = table.concat(ngx.ctx.buf)
local ok, data = pcall(json.decode, body)
if ok then
data.api_version = "v2"
data._internal_field = nil
ngx.arg[1] = json.encode(data)
else
-- Graceful degradation: pass original body through
ngx.log(ngx.WARN, "json-rewriter: response parse error")
ngx.arg[1] = body
end
-- ngx.arg[2] remains true (EOF), signalling end of body to the client
}
}
Critical: The naive pattern that only calls
json.decodeinside theif eof thenblock but does not accumulate intermediate chunks viangx.ctx.bufdiscards all data before the final chunk. The pattern above is the correct form.
Decision Matrix
| Scenario | Recommended approach | Gateway | Key config |
|---|---|---|---|
| Add/remove top-level fields, payload ≤ 1 MB | Full buffer, synchronous Lua/plugin | Kong 3.x, Envoy 1.32+ | get_raw_body() / body():getBytes() |
| Whole-document schema upgrade, payload 1–10 MB | Full buffer with size guard + hard timeout | NGINX/OpenResty | client_max_body_size 10m, body_filter_by_lua_block |
| Strip a single field from a large response, payload > 10 MB | Streaming JSON library (simdjson via FFI) or bypass + upstream-side transform |
NGINX/OpenResty | proxy_buffer_size, proxy_buffers tuned per upstream |
| Schema migration across multiple nested paths | JSONPath mapping rule (declarative) | Tyk 5.x transform_jq |
.user.profile.id as $id | .metadata.tenant_id = $id |
| Multi-tenant field filtering by JWT claim | Auth-aware body filter after JWT validation | Kong 3.x | kong.request.get_header("X-Consumer-Custom-Id") in plugin |
Gotchas and Failure Signals
Stale Content-Length → truncated or rejected payloads
Symptom: Client receives a response that ends mid-JSON, or upstream returns 400 Bad Request / 413 Payload Too Large immediately after the gateway rewrites the request.
Root cause: The original Content-Length header is valid for the pre-mutation byte count. HTTP/1.1 keep-alive connections use this value to delimit body boundaries. After mutation, the byte length almost always differs.
Fix: Immediately after json.encode(data), calculate #new_body (Lua counts UTF-8 bytes) and call set_header("Content-Length", tostring(#new_body)). If you cannot guarantee exact byte length (e.g., you are using a streaming emit), strip Content-Length entirely and let the stack fall back to Transfer-Encoding: chunked.
Worker OOM under sustained load
Symptom: Gateway worker processes are OOM-killed; upstream receives 502 Bad Gateway in bursts aligned with GC cycles.
Root cause: Deserialising payloads larger than 5 MB can consume 3–5× the raw payload size in Lua VM heap (AST nodes, string interning, garbage). Under concurrent load, multiple workers hit their ceiling simultaneously.
Fix:
- Gate transformation on
Content-Length: skip mutation and pass through payloads above your memory budget (if tonumber(ngx.var.content_length or "0") > 2097152 then return end). - Tune
lua_shared_dictandproxy_buffer_sizeto match realistic payload ceilings, not defaults. - Replace
cjsonwithsimdjsonvia FFI for payloads where parse speed dominates allocation cost.
UTF-8 BOM corruption
Symptom: JSON parse error on otherwise valid-looking payloads; the error position is always byte 1.
Root cause: A UTF-8 Byte Order Mark (EF BB BF) prepended by some Windows-origin clients or legacy SDKs is not valid JSON and breaks every standard parser’s tokeniser.
Fix: Strip the BOM before decode: body = body:gsub("^\239\187\191", ""). Then enforce Content-Type: application/json; charset=utf-8 on the mutated response to prevent re-introduction.
Regex-based field mutation
Symptom: Intermittent parse errors in downstream services correlated with specific field values (numbers, nested objects, Unicode strings).
Root cause: String-level regex replacement of JSON fields breaks on escaped characters, numbers without string delimiters, and nested structures with the same key name.
Fix: Never use regex on JSON. Always deserialise → mutate object → re-serialise. The cost of the round-trip is under 1 ms for payloads below 100 KB with cjson or simdjson.
Validation Checklist
-
Content-Lengthis replaced (or stripped) after every mutation before the body is forwarded. - Transformation is wrapped in
pcall(Lua) ortry/catch— parse failures pass through the original body, not a 500. - A size guard rejects or bypasses mutation for payloads above the configured memory ceiling.
-
Content-Type: application/jsonis verified before attempting parse (binary or form bodies must not enter the JSON parser). - UTF-8 BOM stripping is applied before
json.decode. - No regex-based field replacement anywhere in the transform logic.
- Structured metrics (
bytes_processed,parse_errors,mutation_latency_ms) are emitted per request and wired into your observability stack. - Gateway-level buffer limits (
client_max_body_size,lua_package_path,proxy_buffer_size) are set explicitly in config, not left at defaults. - Response mutation is tested against chunked-encoding upstream responses, not just single-chunk test fixtures.
FAQ
Should I buffer the full JSON body or use streaming transforms at the gateway?
Buffer when you need whole-document mutations (field rename, schema upgrade) and payload size is bounded and known. Use streaming when payloads exceed your worker memory ceiling or when you only need to mutate isolated fields accessible without full AST construction. Most gateways default to buffering; opt into streaming explicitly via a size guard.
Why does my upstream receive a 400 after gateway JSON rewriting?
The most common cause is a stale Content-Length header. When the gateway mutates the body, the byte length almost always changes. Replace Content-Length immediately after re-serialisation, or strip it to force Transfer-Encoding: chunked. Never rely on implicit gateway recalculation.
Is regex-based JSON field replacement safe at the gateway layer?
No. Regex patterns break on nested objects, Unicode escapes, and numbers formatted without quotes. Always deserialise to an AST, mutate the in-memory object, then re-serialise with a proper JSON library.
Up: Request & Response Transformation
Related
- Authentication Proxying & Token Validation — body transforms that depend on JWT claims must run after token verification in the middleware chain.
- Configuring CORS Policies for Multi-Tenant APIs — when body rewriting changes response shape, ensure CORS preflight and actual-request headers remain consistent with the mutated payload.
- Dynamic Rate Limiting with Redis Backends — gateway plugins that inspect JSON body fields to drive per-tenant rate-limit decisions sit in the same plugin execution phase as body rewriters.