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, access phase, 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.

JSON Payload Rewriting — Buffered vs Streaming Diagram showing two parallel paths through the gateway proxy. The buffered path accumulates all chunks before parsing and mutating. The streaming path processes chunks incrementally and only reassembles at EOF. FULL BUFFER MODEL STREAMING / CHUNKED MODEL Incoming HTTP body stream Accumulate all chunks in memory Parse full JSON → AST Mutate fields + re-serialise Update Content-Length → forward Receive chunk N (suppress output; store in ctx) EOF signal received? No → loop back for next chunk Concatenate → parse → mutate Emit final body (chunked TE)

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. Configure per_route_configmax_request_bytes to 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.decode inside the if eof then block but does not accumulate intermediate chunks via ngx.ctx.buf discards 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_dict and proxy_buffer_size to match realistic payload ceilings, not defaults.
  • Replace cjson with simdjson via 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-Length is replaced (or stripped) after every mutation before the body is forwarded.
  • Transformation is wrapped in pcall (Lua) or try/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/json is 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