Request Validation & Schema Enforcement

Every service validates its input. The question this topic answers is which validation belongs at the gateway instead of, or as well as, in the service — and the answer is narrower than vendors suggest and wider than most teams implement. Structural validation at the edge stops malformed and oversized requests before they consume a connection pool slot, gives every service the same rejection semantics, and turns an OpenAPI document from documentation into an enforced contract. Business validation at the edge, by contrast, is a duplicated rule that will drift. This page covers where the line sits, what it costs, and how the two major gateways implement it. It sits within the middleware chain between authentication and transformation.

Architectural baseline

Structural versus semantic. Structural validation asks whether the request is well formed: valid JSON, required fields present, types correct, values within declared bounds, no unknown fields where the schema forbids them. Semantic validation asks whether the request makes sense: does this customer exist, is this account in credit, may this user act on this resource. The first is a property of the message and belongs wherever it is cheapest to check. The second requires state the gateway does not have and must not acquire.

Validation runs after identity, before transformation. The order matters: rejecting an unauthenticated request is cheaper than parsing its body, and a transformation written against a validated payload does not need defensive checks of its own. This position in the chain is the one described in request and response transformation.

Validation implies buffering. Checking a body means having the body, which means holding it in memory for the duration. That is the real cost, and it is why size caps and validation belong together rather than as separate decisions.

Where the line sits

The dividing question is whether the check needs application state Content type, body size, JSON well-formedness, required fields, types and enumerated values can all be checked from the message alone and belong at the gateway. Whether a referenced entity exists, whether an account has funds and whether a user may act belong to the service because they require state the gateway does not hold. gateway — decidable from the message alone content-type matches what the route accepts body under the declared size limit parses as well-formed JSON required fields present, types correct enums, ranges, string formats unknown fields rejected, where declared service — needs state the gateway lacks does customer 8891 exist is the account in credit may this user act on this resource is this state transition legal does this violate a business rule is this a duplicate submission A check pulled left of the line that needs state on the right is the anti-pattern: it makes the gateway query a database, couples deploys, and produces two implementations of one rule that drift within a quarter.

What edge validation is actually for

Three benefits justify the buffering cost, and it is worth being clear which ones you are buying.

Uniform rejection semantics. Twelve services behind one gateway produce twelve different shapes of validation error unless something normalises them. A single schema-validation filter gives every consumer the same status code, the same error body and the same field-path convention, and that consistency is worth more to API consumers than almost any individual feature.

Cheap rejection. A malformed request rejected at the edge never occupies an upstream connection, never wakes a service, and never appears in that service’s error budget. Under an attack composed of malformed payloads, this is the difference between a busy gateway and a fleet-wide outage.

A contract that is enforced rather than described. An OpenAPI document that generates documentation drifts from reality. The same document loaded into the gateway as an enforcement policy cannot drift, because a request that does not match it does not arrive.

Implementation on the two major gateways

# Kong 3.x — validate against an inline JSON schema, per route
plugins:
  - name: request-validator
    route: orders-create
    config:
      version: draft4
      body_schema: |
        {
          "type": "object",
          "required": ["customerId", "items", "currency"],
          "additionalProperties": false,
          "properties": {
            "customerId": { "type": "string", "pattern": "^[0-9a-f]{8}$" },
            "currency":   { "type": "string", "enum": ["EUR", "USD", "GBP"] },
            "items": {
              "type": "array", "minItems": 1, "maxItems": 100,
              "items": {
                "type": "object",
                "required": ["sku", "qty"],
                "properties": {
                  "sku": { "type": "string", "maxLength": 32 },
                  "qty": { "type": "integer", "minimum": 1, "maximum": 999 }
                }
              }
            }
          }
        }
      verbose_response: true      # return the failing field path, not just "invalid"
      allowed_content_types: ["application/json"]
# Envoy 1.32+ — bound the body first, then validate in an external processor
http_filters:
  - name: envoy.filters.http.buffer
    typed_config:
      "@type": type.googleapis.com/envoy.extensions.filters.http.buffer.v3.Buffer
      max_request_bytes: 131072          # 128 KiB — reject larger before buffering
  - name: envoy.filters.http.ext_proc
    typed_config:
      "@type": type.googleapis.com/envoy.extensions.filters.http.ext_proc.v3.ExternalProcessor
      grpc_service:
        envoy_grpc: { cluster_name: validator }
        timeout: 0.2s                    # a validator outage must not hang requests
      processing_mode:
        request_body_mode: BUFFERED
        response_body_mode: NONE
      failure_mode_allow: false          # a validation service that is down rejects
  - name: envoy.filters.http.router

The failure_mode_allow decision is the one to make consciously. Setting it false means a validator outage rejects traffic; setting it true means malformed requests reach upstreams during that outage. For a validator that is the only thing between the internet and an unhardened service, false is right. For one that mainly improves error messages, true is right.

The cost, and how to bound it

Validation is cheap until the payload is not A two kilobyte payload validates in tens of microseconds against a negligible memory cost. A hundred kilobyte payload costs around a millisecond. A five megabyte payload costs tens of milliseconds and, multiplied by concurrency, becomes the dominant memory consumer on the node. Capping the body size is what keeps the third row from ever occurring. payload size validation time memory at 500 concurrent 2 KB — typical tens of microseconds 1 MB — irrelevant 100 KB — large about 1 ms 50 MB — noticeable 5 MB — uncapped tens of ms, CPU-bound 2.5 GB — the node dies The size cap is not a secondary hygiene setting; it is the control that makes validation safe to enable at all. Reject oversized bodies on the headers, before a byte of payload has been read.

Response validation, and why almost nobody should do it

The mirror-image question comes up regularly: should the gateway validate responses against the schema too? It is technically straightforward and almost always the wrong trade.

The cost is doubled buffering, on the path where payloads are largest, and a new failure mode in which a service returning a technically-invalid-but-usable response produces a 502 for the caller instead. The benefit is catching a contract violation before the consumer sees it — which sounds valuable until you consider that the consumer would have seen a schema violation either way, and now sees a gateway error with less information in it.

The exception is a report-only response check during a migration, where the point is to discover drift rather than to block it. Log the mismatches, alert on the rate, and let the response through. That is the mechanism described in backward-compatibility contracts, and it belongs in a monitoring configuration rather than an enforcement one.

Comparative implementation

Capability Kong 3.x Envoy 1.32+
Inline JSON Schema native plugin via external processor
OpenAPI document as the source enterprise plugin external processor or WASM
Reject unknown fields additionalProperties: false schema-dependent
Body size cap client_max_body_size buffer filter
Failure mode when the validator is down plugin-local, always fails closed failure_mode_allow, explicit
Error body shape plugin-defined, configurable verbosity whatever the processor returns
Cost in-process Lua an inter-process hop unless in WASM

Rolling it out without an incident

Turning validation on across an existing API always finds traffic the schema does not describe, and that traffic is usually legitimate. The safe sequence is to run in report-only mode first: validate, log the failures with the route and the failing field path, and forward the request anyway. A week of that produces a list of schema corrections and a small number of genuine client bugs, at which point enforcement can be enabled per route rather than globally.

Report first, enforce second In the first days of report-only mode a large share of requests fail validation, almost all because the schema was incomplete rather than because clients were wrong. As schemas are corrected the failure rate falls to a small residue of genuine client errors, and only then is enforcement switched on. fail % report-only: schema gaps found and fixed enforce from here day 1 day 14 Enabling enforcement on day one turns every schema gap into a client-visible outage — and the schema always has gaps.

Validating query parameters and headers, not only bodies

Body validation gets the attention because it is the expensive part, but a large share of real defects arrive in the query string. A pagination parameter with no upper bound is a denial-of-service primitive: a caller asking for a hundred thousand records per page will occupy a database connection for as long as it takes, and no amount of downstream rate limiting helps because the request rate is low. A date range with no maximum span has the same shape. A sort field passed straight into a query builder has a worse one.

These are all structural checks — a bound, an enumeration, a pattern — and they are decidable from the message alone, which puts them squarely at the gateway. They are also far cheaper than body validation, because there is nothing to buffer.

# Kong 3.x — the query and header half of the same plugin
config:
  parameter_schema:
    - name: limit
      in: query
      required: false
      schema: '{"type":"integer","minimum":1,"maximum":200}'
      style: form
    - name: sort
      in: query
      required: false
      schema: '{"type":"string","enum":["createdAt","-createdAt","total","-total"]}'
      style: form
    - name: x-idempotency-key
      in: header
      required: true
      schema: '{"type":"string","minLength":16,"maxLength":64}'
      style: simple

The enumeration on sort is the important line. Accepting an arbitrary string and passing it to an upstream that builds a query from it is the classic injection shape, and an allow-list at the edge closes it for every service behind the gateway at once rather than one service at a time.

What to do about content types you did not expect

A route that accepts JSON will receive form encodings, XML, multipart uploads and occasional binary noise. Declaring the accepted content types and rejecting everything else is one line of configuration and removes a whole class of upstream behaviour — frameworks that helpfully attempt to parse whatever arrives are a reliable source of surprising bugs.

The case worth thinking about is the request with no content type at all, which is more common than it should be from hand-rolled clients and some older SDKs. Treating it as the route’s default is friendly and hides a client bug; rejecting it is strict and produces a support ticket. The defensible middle is to reject on write routes, where guessing wrong changes state, and default on read routes, where it does not.

Operational gotchas

A verbose error body is an information disclosure decision. Returning the failing field path helps legitimate clients enormously and tells an attacker the shape of your internal model. Return field paths, not schema fragments or internal type names.

Schemas drift from services unless generated. A hand-maintained gateway schema and a hand-maintained service model diverge within a quarter. Generate the gateway’s schema from the same source the service uses, and fail the build when they disagree — the mechanism is the one in consumer-driven contract testing at the gateway.

additionalProperties: false is a breaking change waiting to happen. It is the right default for a new API and a hazard on an existing one, because any client sending a field you forgot to declare starts failing at once.

Validation errors are 400, not 422, unless you have decided otherwise and documented it. Consumers write retry logic against these codes, and a service that switches between them mid-life breaks clients that were being careful.

Production configuration checklist

  • Body size cap set on every route that validates, enforced before buffering
  • Validation runs after authentication and before transformation
  • Failure mode when the validator is unavailable chosen explicitly per route
  • Report-only mode run for at least a week before enforcement on existing routes
  • Error bodies carry field paths and no internal type or schema detail
  • Schemas generated from the same source as the service model, with a CI check
  • Rejection rate per route exported as a metric, alerting on a sudden change
  • Status code convention documented and stable

FAQ

Should the gateway validate request bodies at all, or is that the service’s job?

Both, for different things. Structural validation — content type, size, well-formedness, required fields, types, enumerated values — is decidable from the message alone and is cheapest at the edge, where it also gives every service the same rejection semantics. Semantic validation needs application state and belongs in the service. A gateway rule that requires a database lookup has crossed the line and will drift from the service that owns the same rule.

What does validation cost?

Mostly memory, because checking a body means buffering it. At typical payload sizes the CPU cost is tens of microseconds and irrelevant. At a few megabytes, multiplied by concurrency, buffering becomes the dominant memory consumer on the node. That is why a body size cap enforced on the headers, before any payload is read, is what makes validation safe to enable rather than an optional extra.

How do I enable validation on an API that is already live?

Report-only first. Validate, log the route and failing field path, and forward the request anyway. A week of that produces a list of schema gaps — which are almost always the cause rather than client bugs — and a small residue of genuine errors. Fix the schemas, then enable enforcement route by route. Enforcing on day one turns every gap into a client-visible outage.

Should validation errors return 400 or 422?

Either, provided you choose once and document it. 400 is the common choice for a malformed or schema-invalid body and is what most client libraries expect to treat as non-retryable. What matters more than the number is stability: consumers write retry and alerting logic against these codes, and a service that changes convention mid-life breaks exactly the clients that were being careful.


Parent: Middleware Chains & Request Transformation