API Schema Validation as a Security Control: OpenAPI Enforcement and the Mass Assignment Problem

API Schema Validation as a Security Control: OpenAPI Enforcement and the Mass Assignment Problem

The Problem

In 2012, Egor Homakov submitted a pull request to Rails that demonstrated how GitHub’s mass assignment vulnerability allowed any authenticated user to add their SSH key to any repository’s deploy keys — without permission. He did it by passing public_key[user_id]=4223 in a POST body that the controller mapped directly to the PublicKey model. The field wasn’t listed as forbidden; it simply wasn’t listed at all. GitHub patched it within hours. The underlying pattern — application code that automatically maps request fields to internal model attributes without an explicit allowlist — remains one of the most reliably exploitable vulnerability classes in web APIs, fifteen years later.

Mass assignment goes by several names depending on the framework: auto-binding in Spring MVC, attribute mass assignment in Rails, direct hydration in Laravel. The mechanism is the same in all of them. A developer writes a handler that deserialises a JSON body and passes it to an ORM or model constructor. The framework helpfully maps every field in the body to every attribute it can find on the target model. The developer tests with the fields the feature requires. The fields the attacker will add — role, is_admin, is_staff, plan, verified, subscription_tier — are never tested, because from the attacker’s perspective they don’t need to be. They just need to be present on the model.

# VULNERABLE: Django-style mass assignment
class UserUpdateView(APIView):
    def patch(self, request, user_id):
        user = User.objects.get(id=user_id)
        # Maps ALL fields from the request body to the model instance
        # No allowlist. No rejection of unexpected fields.
        user.__dict__.update(request.data)
        user.save()

# Legitimate request — what the developer tested:
# PATCH /users/123
# {"name": "Alice", "email": "alice@example.com"}

# Attack request — what the developer didn't test:
# PATCH /users/123
# {"name": "Alice", "email": "alice@example.com",
#  "role": "admin", "is_staff": true, "subscription_tier": "enterprise",
#  "verified": true, "email_confirmed_at": "2020-01-01T00:00:00Z"}
# Result: Alice is now an admin with a free enterprise subscription

The attack requires no knowledge of internal implementation details beyond what the model’s field names likely are — and those are predictable. They follow conventional naming patterns (is_admin, admin, role, plan, tier, verified, staff), they appear in error messages when you send malformed requests, and they sometimes appear in OpenAPI specs that describe other endpoints. A blind scan of common field names against a profile update endpoint takes minutes and costs nothing.

Real incident pattern: HackerOne disclosed mass assignment vulnerabilities against Shopify (2019), GitLab (2020), and multiple SaaS platforms where users could elevate their own privileges or modify subscription tiers by including extra fields in account update requests. In each case the fix was the same: add an explicit allowlist of fields that a given endpoint is permitted to modify.

The allowlist can live in the application, and for defence-in-depth purposes it should. But application-layer allowlists have a structural failure mode: application teams forget to update them when adding new fields, validation is frequently omitted from “internal” endpoints that developers assume won’t be externally accessible, and inconsistent validation across API versions creates exploitable gaps between v1 and v2 of the same endpoint. A security engineer reviewing 40 microservices cannot audit the allowlist logic in every handler of every service and trust that it stays correct as the codebase evolves.

Gateway-level schema validation catches all endpoints uniformly, validates before the request reaches any application code, and is enforced from a single configuration source that security teams control independently of application developers.

Threat Model

Mass assignment via unexpected fields → privilege escalation. An attacker sends a PATCH request to any user-writable endpoint with additional fields that correspond to privileged model attributes. If the application maps request fields to model attributes without an explicit allowlist, the attacker self-escalates to admin, modifies their subscription tier, or marks their account verified. The attack requires only a valid session token — no injection, no memory corruption.

Oversized payload → application memory exhaustion → DoS. Without payload size limits, a client can send a 500MB JSON body to any endpoint that accepts request bodies. The application deserialises the full payload before any validation logic runs. At sufficient volume, this exhausts heap memory and kills the process. A gateway that rejects requests exceeding a per-endpoint size limit absorbs the attack before the application is involved.

Malformed input bypassing application validation → injection. Schema validation as defence in depth: if the gateway enforces that a field must match type string with pattern ^\+[1-9]\d{1,14}$, a phone number field cannot contain SQL metacharacters or shell metacharacters regardless of what the application’s validation logic does. The schema constraint is the first filter; the application’s input handling is the second. A bypass requires compromising both layers.

Type confusion → unexpected code path. Sending an integer where a string is expected, or an array where a scalar is expected, frequently triggers code paths that were never tested because they are never reachable from the normal UI. Type confusion can bypass length checks (an integer has no length), trigger implicit string conversion that strips sanitisation, or reach error handling code that logs more than it should. Strict type validation at the schema layer eliminates type confusion before it reaches the application.

Undeclared endpoint exposure. A service adds an admin endpoint — /api/internal/users/{id}/promote — that is not in the OpenAPI spec and is therefore not covered by gateway validation. The developer intends it to be called only from a trusted internal service, but the gateway route is misconfigured and the endpoint is externally reachable. Without schema validation on that endpoint, mass assignment on a bare endpoint proceeds unchecked.

How Schema Validation Prevents Mass Assignment

JSON Schema’s additionalProperties: false constraint is the direct prevention mechanism. When a schema declares additionalProperties: false, any field in the request body that is not listed under properties causes the entire request to be rejected with a 400 response. The request never reaches the application.

The constraint has to be declared explicitly — the default behaviour of JSON Schema is to allow additional properties. A spec that lists name, email, and phone under properties but does not set additionalProperties: false validates those three fields and passes everything else through, including role and is_admin. This is the most common misconfiguration: a spec that documents the allowed fields but does not enforce their exclusivity.

Three validation layers work together:

  1. Type validation: field values must match declared types. name is string, not integer or array. age is integer, not "twenty-three".
  2. Presence and exclusivity validation: required fields must be present; fields not declared in properties are rejected when additionalProperties: false is set. This is the mass assignment prevention layer.
  3. Value validation: enum values restrict strings to a declared set, minLength/maxLength bound string sizes, minimum/maximum bound numeric ranges, pattern constrains strings to a regex. These prevent injection through format constraints.

Hardening Configuration

1. OpenAPI Spec with additionalProperties: false

Every request body schema that accepts a JSON object must set additionalProperties: false. Every property must have type constraints. String fields must have maxLength. This is the spec-level declaration; the gateway enforces it.

# openapi.yaml — strict schema declarations for user endpoints
openapi: "3.0.3"
info:
  title: User API
  version: "1.0"

paths:
  /users/{userId}:
    patch:
      operationId: updateUser
      parameters:
        - name: userId
          in: path
          required: true
          schema:
            type: string
            pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UserUpdateRequest'
      responses:
        '200':
          description: User updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserResponse'
        '400':
          description: Validation error — unexpected fields or invalid values
        '422':
          description: Semantic validation error

components:
  schemas:
    UserUpdateRequest:
      type: object
      additionalProperties: false  # Rejects any field not listed below
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 100
        email:
          type: string
          format: email
          maxLength: 255
        phone:
          type: string
          pattern: "^\\+[1-9]\\d{1,14}$"  # E.164 format
        preferences:
          $ref: '#/components/schemas/UserPreferences'
      # Intentionally absent: role, is_staff, is_admin, plan, verified,
      # subscription_tier, email_confirmed_at, created_at, updated_at.
      # These fields are rejected at the schema level before reaching the app.

    UserPreferences:
      type: object
      additionalProperties: false  # Nested objects need this too
      properties:
        theme:
          type: string
          enum: ["light", "dark", "system"]
        notifications_enabled:
          type: boolean
        timezone:
          type: string
          maxLength: 64
          pattern: "^[A-Za-z_/]{1,64}$"

    UserResponse:
      type: object
      additionalProperties: false
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        email:
          type: string
        # role, is_staff excluded from response too — response schemas matter

Note that additionalProperties: false must be applied recursively to nested object schemas. A top-level declaration covers only the root object; nested objects like preferences in the example above require their own additionalProperties: false or an attacker can use nested fields as a vector.

2. Kong Gateway Schema Validation Plugin

Kong’s request-validator plugin validates request bodies against a JSON Schema definition at the proxy layer. The plugin rejects non-conforming requests before they are proxied upstream. Deploy this plugin on every route that accepts a request body — not just routes you consider sensitive.

# kong-request-validator.yaml
# Applies JSON Schema validation at the Kong proxy layer
apiVersion: configuration.konghq.com/v1
kind: KongPlugin
metadata:
  name: user-update-validator
  namespace: api-gateway
plugin: request-validator
config:
  # JSON Schema draft4 — Kong's supported version
  body_schema: |
    {
      "$schema": "http://json-schema.org/draft-04/schema#",
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "name": {
          "type": "string",
          "minLength": 1,
          "maxLength": 100
        },
        "email": {
          "type": "string",
          "maxLength": 255
        },
        "phone": {
          "type": "string",
          "pattern": "^\\+[1-9]\\d{1,14}$"
        }
      }
    }
  allowed_content_types:
    - "application/json"
  version: draft4
  verbose_response: false  # Do not expose schema details in error responses
---
# Apply the plugin to the specific route
apiVersion: configuration.konghq.com/v1
kind: KongIngress
metadata:
  name: user-api-route
spec:
  route:
    methods:
      - PATCH
    protocols:
      - https
---
# Attach plugin to the route
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: user-api
  annotations:
    konghq.com/plugins: user-update-validator
    konghq.com/strip-path: "true"
spec:
  rules:
  - http:
      paths:
      - path: /api/users
        backend:
          serviceName: user-service
          servicePort: 8080

Kong’s verbose_response: false setting is important: when set to true, the validation error response includes the schema definition, which tells attackers exactly which fields are accepted and therefore which fields to look for elsewhere. The 400 response body should say "Validation failed" or similar — nothing more.

3. Envoy External Authorization Filter for Schema Validation

For service meshes using Envoy as a sidecar, schema validation can be enforced at the proxy layer via a Lua filter or an external authorisation service. The Lua filter runs inline in the Envoy data path with no additional network hop.

-- envoy-schema-validator.lua
-- Enforces field allowlists at the Envoy proxy layer
-- Deploy as an HttpConnectionManager Lua filter

local cjson = require "cjson"

-- Per-route field allowlists.
-- Keys are pattern strings; values are sets of allowed field names.
local ROUTE_ALLOWLISTS = {
    {pattern = "^/api/users/[^/]+$", methods = {"PATCH"}, fields = {
        name = true, email = true, phone = true
    }},
    {pattern = "^/api/orders$", methods = {"POST"}, fields = {
        items = true, shipping_address = true, payment_token = true,
        promo_code = true
    }},
    {pattern = "^/api/auth/login$", methods = {"POST"}, fields = {
        username = true, password = true, totp_code = true
    }},
}

local function match_route(path, method)
    for _, rule in ipairs(ROUTE_ALLOWLISTS) do
        if string.match(path, rule.pattern) then
            for _, m in ipairs(rule.methods) do
                if m == method then
                    return rule.fields
                end
            end
        end
    end
    return nil
end

local function find_unexpected_fields(parsed, allowed_fields)
    for field, _ in pairs(parsed) do
        if not allowed_fields[field] then
            return field
        end
    end
    return nil
end

function envoy_on_request(request_handle)
    local method = request_handle:headers():get(":method")

    -- Only validate write requests
    if method ~= "POST" and method ~= "PUT" and method ~= "PATCH" then
        return
    end

    local content_type = request_handle:headers():get("content-type") or ""
    if not string.find(content_type, "application/json") then
        return
    end

    local path = request_handle:headers():get(":path")
    -- Strip query string for pattern matching
    local path_only = string.match(path, "^([^?]+)") or path

    local allowed_fields = match_route(path_only, method)
    if not allowed_fields then
        -- No rule defined for this route — allow through (monitor, don't block)
        -- Change to block-by-default by returning 400 here
        return
    end

    local body = request_handle:body()
    if not body or body:length() == 0 then
        return
    end

    -- Enforce payload size limit: 64KB for most endpoints
    if body:length() > 65536 then
        request_handle:respond(
            {[":status"] = "413", ["content-type"] = "application/json"},
            cjson.encode({error = "Payload too large"})
        )
        return
    end

    local raw = body:getBytes(0, body:length())
    local ok, parsed = pcall(cjson.decode, raw)

    if not ok then
        request_handle:respond(
            {[":status"] = "400", ["content-type"] = "application/json"},
            cjson.encode({error = "Invalid JSON"})
        )
        return
    end

    if type(parsed) ~= "table" then
        request_handle:respond(
            {[":status"] = "400", ["content-type"] = "application/json"},
            cjson.encode({error = "Request body must be a JSON object"})
        )
        return
    end

    local unexpected = find_unexpected_fields(parsed, allowed_fields)
    if unexpected then
        -- Log the field name internally but do not return it in the response
        request_handle:logWarn("Rejected request with unexpected field from " ..
            (request_handle:headers():get("x-forwarded-for") or "unknown"))
        request_handle:respond(
            {[":status"] = "400", ["content-type"] = "application/json"},
            cjson.encode({error = "Request contains unexpected fields"})
        )
        return
    end
end

The Lua filter applies to every request that reaches the sidecar. The ROUTE_ALLOWLISTS table is the security-critical configuration: it must be kept in sync with the OpenAPI spec. This is the failure mode addressed by the CI check in section 6.

4. Pydantic v2 Strict Mode (Application-Layer Defence in Depth)

Gateway validation is the primary control. Application-layer validation is defence in depth — it catches anything that bypasses the gateway (direct service-to-service calls, misconfigured routes, gateway outages). Pydantic v2’s extra="forbid" and strict=True implement both mass assignment prevention and strict type checking at the model level.

# Python: Pydantic v2 — defence-in-depth schema enforcement
from pydantic import BaseModel, ConfigDict, EmailStr, field_validator
from pydantic import model_validator
import re

PHONE_PATTERN = re.compile(r"^\+[1-9]\d{1,14}$")

class UserUpdateRequest(BaseModel):
    model_config = ConfigDict(
        extra="forbid",       # Raise ValidationError on any unknown field
        strict=True,          # No implicit coercion: "123" != 123
        str_max_length=255,   # Global maximum for all string fields
        str_strip_whitespace=True,
    )

    name: str | None = None
    email: EmailStr | None = None
    phone: str | None = None

    @field_validator("name")
    @classmethod
    def name_not_empty(cls, v: str | None) -> str | None:
        if v is not None and len(v.strip()) == 0:
            raise ValueError("name cannot be empty")
        return v

    @field_validator("phone")
    @classmethod
    def phone_e164(cls, v: str | None) -> str | None:
        if v is not None and not PHONE_PATTERN.match(v):
            raise ValueError("phone must be in E.164 format")
        return v

    @model_validator(mode="after")
    def at_least_one_field(self) -> "UserUpdateRequest":
        if self.name is None and self.email is None and self.phone is None:
            raise ValueError("At least one field must be provided")
        return self


# FastAPI usage
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.responses import JSONResponse

app = FastAPI()

@app.patch("/api/users/{user_id}")
async def update_user(
    user_id: str,
    body: UserUpdateRequest,   # Pydantic validates on deserialisation
    current_user: User = Depends(require_auth),
):
    # body.model_dump() contains ONLY: name, email, phone
    # If the client sent role=admin, it raised ValidationError → 422 response
    # before this handler executed.
    updates = body.model_dump(exclude_none=True)

    if not updates:
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
            detail="No fields to update",
        )

    updated = await user_repository.update(user_id, updates, actor=current_user.id)
    return JSONResponse(content=updated.to_response_dict())


# Custom exception handler: don't leak field names in error messages
from fastapi.exceptions import RequestValidationError

@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, exc):
    # Log the full error internally
    logger.warning("Validation error", extra={"errors": exc.errors(), "path": str(request.url)})
    # Return a minimal response to the client
    return JSONResponse(
        status_code=422,
        content={"error": "Request validation failed"},
    )

The strict=True configuration is worth examining separately. In Pydantic v2’s default (lax) mode, sending "true" as the value of a boolean field succeeds — Pydantic coerces the string to True. In strict mode, that raises a ValidationError. This matters because type coercion can bypass length checks (len(True) raises TypeError), and implicit coercion is exploited in type confusion attacks where sending a value of unexpected type reaches a code path that assumes the coercion already happened.

5. Payload Size Limits at the Reverse Proxy

Payload size limits must be set per endpoint type. A global limit either blocks legitimate large uploads or permits excessively large bodies on endpoints that should accept only small payloads. Set limits conservatively and expand them only for endpoints that require larger payloads.

# nginx: per-location payload size limits
# Default: deny oversized payloads before the app sees them
client_max_body_size 0;  # Override per location — never use global default

server {
    listen 443 ssl;
    server_name api.example.com;

    # Authentication endpoints: tiny — credentials only
    location ~ ^/api/auth/ {
        client_max_body_size 4k;
        proxy_pass http://auth-service;
    }

    # User profile updates: small — a few fields
    location ~ ^/api/users/ {
        client_max_body_size 16k;
        proxy_pass http://user-service;
    }

    # Order creation: medium — line items, address, payment token
    location ~ ^/api/orders {
        client_max_body_size 64k;
        proxy_pass http://order-service;
    }

    # Document uploads: large — actual file content
    location ~ ^/api/documents/upload {
        client_max_body_size 10m;
        proxy_pass http://document-service;
    }

    # Catch-all: reject if no specific rule matched
    location / {
        client_max_body_size 8k;
        proxy_pass http://default-service;
    }
}

For Kong, the request-size-limiting plugin provides equivalent per-route enforcement:

apiVersion: configuration.konghq.com/v1
kind: KongPlugin
metadata:
  name: auth-size-limit
plugin: request-size-limiting
config:
  allowed_payload_size: 4      # kilobytes
  size_unit: kilobytes
  require_content_length: true  # Reject chunked transfers without Content-Length

The require_content_length: true setting deserves attention: without it, a client using chunked transfer encoding can stream data without declaring a Content-Length header, which makes size limit enforcement harder. Requiring the header allows Kong to reject oversized requests at the header-reading phase, before buffering any body bytes.

6. Automated Schema Completeness Check in CI

The gap between “schema documents the allowed fields” and “schema enforces the allowed fields” is exactly additionalProperties: false. If this constraint is missing from any request body schema, the spec is documentation without enforcement. This CI check fails the pipeline when any POST, PUT, or PATCH request body schema is missing the constraint.

# .github/workflows/api-schema-check.yaml
name: API Schema Validation
on:
  push:
    paths:
      - 'openapi.yaml'
      - 'openapi/**'
  pull_request:
    paths:
      - 'openapi.yaml'
      - 'openapi/**'

jobs:
  schema-completeness:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Validate OpenAPI spec syntax
        run: |
          npm install -g @redocly/cli
          redocly lint openapi.yaml --format=stylish

      - name: Enforce additionalProperties: false on all request bodies
        run: |
          python3 << 'PYEOF'
          import yaml
          import sys

          def check_schema(schema, path):
              """Recursively check that all object schemas have additionalProperties: false."""
              issues = []
              if not isinstance(schema, dict):
                  return issues

              if schema.get("type") == "object" or "properties" in schema:
                  if schema.get("additionalProperties") is not False:
                      issues.append(f"  Schema at {path}: missing additionalProperties: false")

              # Check nested objects in properties
              for prop_name, prop_schema in schema.get("properties", {}).items():
                  issues.extend(check_schema(prop_schema, f"{path}.properties.{prop_name}"))

              # Check schemas referenced in allOf/anyOf/oneOf
              for combiner in ("allOf", "anyOf", "oneOf"):
                  for i, sub in enumerate(schema.get(combiner, [])):
                      issues.extend(check_schema(sub, f"{path}.{combiner}[{i}]"))

              return issues

          def resolve_ref(spec, ref):
              """Resolve a $ref to its target schema."""
              if not ref.startswith("#/"):
                  return {}
              parts = ref.lstrip("#/").split("/")
              target = spec
              for part in parts:
                  target = target.get(part, {})
              return target

          with open("openapi.yaml") as f:
              spec = yaml.safe_load(f)

          write_methods = {"post", "put", "patch"}
          all_issues = []

          for path, path_item in spec.get("paths", {}).items():
              for method, operation in path_item.items():
                  if method not in write_methods:
                      continue

                  request_body = operation.get("requestBody", {})
                  for content_type, content in request_body.get("content", {}).items():
                      schema = content.get("schema", {})

                      # Resolve top-level $ref
                      if "$ref" in schema:
                          schema = resolve_ref(spec, schema["$ref"])

                      location = f"{method.upper()} {path} ({content_type})"
                      issues = check_schema(schema, location)
                      all_issues.extend(issues)

          if all_issues:
              print("Schema completeness failures — missing additionalProperties: false:")
              for issue in all_issues:
                  print(issue)
              sys.exit(1)

          print("All request body schemas have additionalProperties: false")
          PYEOF

      - name: Check for undeclared endpoints
        run: |
          # Verify every route in the router has a corresponding OpenAPI path
          # Adjust the grep pattern for your framework's routing syntax
          python3 << 'PYEOF'
          import yaml
          import subprocess
          import re
          import sys

          with open("openapi.yaml") as f:
              spec = yaml.safe_load(f)

          spec_paths = set(spec.get("paths", {}).keys())

          # Extract routes from FastAPI application (example for Python/FastAPI)
          # Adjust for your framework
          result = subprocess.run(
              ["grep", "-r", "@app\.\(get\|post\|put\|patch\|delete\)", "--include=*.py", "-h", "src/"],
              capture_output=True, text=True
          )

          route_pattern = re.compile(r'@app\.\w+\(["\']([^"\']+)["\']')
          code_routes = set()
          for line in result.stdout.splitlines():
              match = route_pattern.search(line)
              if match:
                  code_routes.add(match.group(1))

          undocumented = code_routes - spec_paths
          if undocumented:
              print("Routes in code not in OpenAPI spec (no schema validation):")
              for route in sorted(undocumented):
                  print(f"  {route}")
              sys.exit(1)

          print("All routes are documented in OpenAPI spec")
          PYEOF

The second check — comparing routes in code against paths in the spec — catches the undeclared endpoint failure mode. A route that exists in the application but not in the spec receives no gateway-level validation. This check surfaces the gap in CI before the route reaches production.

Expected Behaviour

Pydantic extra="forbid" rejection — when a client sends {"name": "Alice", "role": "admin"} to a FastAPI endpoint backed by a Pydantic model with extra="forbid":

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json

{"error": "Request validation failed"}

The application logs internally: {"event": "validation_error", "errors": [{"type": "extra_forbidden", "loc": ["body", "role"], "msg": "Extra inputs are not permitted"}], "path": "/api/users/123"}. The client receives only the generic error — the field name role is not in the response, which would confirm to an attacker that the field exists on the model.

Kong request-validator rejection — when a client sends a field not in the body schema:

HTTP/1.1 400 Bad Request
Content-Type: application/json

{"message": "request body validation failed, data is invalid"}

Kong’s default verbose_response includes schema details. With verbose_response: false, the response is minimal. Check Kong’s error log for the field-level detail.

CI schema check output — when a developer adds a new endpoint without additionalProperties: false:

Schema completeness failures — missing additionalProperties: false:
  Schema at POST /api/subscriptions (application/json): missing additionalProperties: false
  Schema at POST /api/subscriptions (application/json).properties.billing_info: missing additionalProperties: false
Error: Process completed with exit code 1.

The PR is blocked. The developer must add additionalProperties: false to both the root schema and the nested billing_info object before the pipeline passes.

Trade-offs

additionalProperties: false breaks clients that send undeclared fields. Some older API clients, mobile apps, and third-party integrations add fields to request bodies speculatively — fields they expect to be ignored. This is technically valid JSON behaviour; JSON Schema’s default permits additional properties precisely because the spec authors considered ignore-unknown to be a reasonable default. Enforcing additionalProperties: false requires coordinating with API consumers and may require a migration period where the schema is permissive for deprecated clients while strict for new ones. Version the API and enforce strict schemas from v2 onwards.

Strict type validation prevents type coercion clients depend on. Some clients send "true" (string) where a boolean is expected, or "123" where an integer is expected, because their HTTP library serialises everything as strings. Pydantic v2’s strict=True rejects these. Gateway-level JSON Schema validation in draft4 is also strict by default for type matching. Audit existing clients before enabling strict validation — fix clients that send incorrect types rather than making the schema permissive.

Payload size limits must be calibrated per endpoint. Setting a global 10KB limit breaks file upload endpoints. Setting a global 10MB limit makes every small endpoint vulnerable to memory exhaustion attacks. The per-location nginx configuration requires ongoing maintenance: every new endpoint type needs a size limit. Use a conservative default (8KB) and require an explicit override for any endpoint that accepts larger payloads, with the override documented in the OpenAPI spec’s x- extensions.

Gateway enforcement requires the gateway to be on the critical path. If any path to the backend service bypasses the gateway — a Kubernetes service accessed directly by another pod, an internal load balancer route added for debugging, a development mode that disables the proxy — schema validation is not applied on that path. The application-layer Pydantic validation catches this, but only if it is present on every handler. Audit ingress routes regularly and ensure no service is directly reachable from outside the mesh without going through gateway validation.

Failure Modes

Schema documents but does not enforce. The OpenAPI spec declares additionalProperties: false, but the gateway plugin is configured with the schema manually, separately from the spec, and that manual copy was never updated to include the constraint. The spec is documentation; the gateway schema is enforcement. They diverge. The CI check in section 6 addresses this only if the gateway configuration is also linted against the spec — add a step that generates the Kong plugin configuration from the spec directly using openapi-to-kong or equivalent.

Validation only on documented endpoints. The gateway enforces schema validation on every path listed in the OpenAPI spec. A service adds a new endpoint — /api/users/{id}/grant-access — that is deployed but not yet added to the spec. The CI undocumented-routes check catches this if it runs against the deployed service, but if the check only reads from the spec file and not from the running application, the gap persists until someone updates the spec. Fail closed: default deny unknown paths at the gateway.

additionalProperties: false missing from nested object schemas. The top-level schema has the constraint, but nested objects do not. An attacker cannot add role at the root level, but can add preferences.internal_flag or address.user_type if those nested schemas are permissive. The recursive schema check in the CI script addresses this.

Application-layer extra="forbid" not applied uniformly. A developer adds a new endpoint and uses a different Pydantic model — one without extra="forbid" — because they copied a template from an older part of the codebase. Add a base model class that all request models inherit from, with extra="forbid" declared in the base:

class SecureRequestModel(BaseModel):
    """Base class for all API request bodies. Never instantiate directly."""
    model_config = ConfigDict(
        extra="forbid",
        strict=True,
        str_max_length=4096,
    )

class UserUpdateRequest(SecureRequestModel):
    name: str | None = None
    email: EmailStr | None = None
    # extra="forbid" inherited — no per-model configuration needed

A linting rule enforcing that all *Request classes inherit from SecureRequestModel — added to your ruff or pylint configuration — closes the inheritance gap automatically.

Error responses that confirm field existence. When a validation error mentions the field name in the response body — "field 'role' is not permitted" — the attacker learns that role is a field the model knows about, which confirms the attack target. Return only generic messages to clients. Log field-level detail internally. The Pydantic exception handler in section 4 demonstrates this separation.