Validate HTTP requests and responses against OpenAPI at runtime: middleware, proxies, and fail-open vs fail-closed
If your OpenAPI document is only consumed by a documentation renderer, it is a brochure. The same schema that tells a developer that email must be an email and amount_minor must be an integer can be enforced at the edge,
If your OpenAPI document is only consumed by a documentation renderer, it is a brochure. The same schema that tells a developer that email must be an email and amount_minor must be an integer can be enforced at the edge, so malformed input never reaches business logic and, just as importantly, so your handlers cannot accidentally return a response that violates the contract you published. Runtime validation turns the spec into an executable guardrail rather than a description. The design decisions are where to run it, what to validate, and what to do when reality and the spec disagree.
Where validation runs
| Location | Examples | Strengths | Trade-offs |
|---|---|---|---|
| App middleware | express-openapi-validator, OpenAPI validator middleware for Fastify/Koa | Closest to routes, rich errors, framework-native | Must be wired per service; language-specific |
| Framework-native routing | openapi-backend (routes and validates from the spec) | Single source drives routing, validation, and mocks | More opinionated structure |
| Gateway / proxy | Envoy, Kong, or a Prism-style validation proxy | Language-agnostic, covers legacy services, central policy | Harder to return app-specific errors; latency at the edge |
| Build-time / tests | Spectral, contract tests, Prism in CI | Catches drift before deploy | Cannot reject a live bad request |
These are complementary, not competing. A common mature setup validates requests in app middleware for precise field-level errors and runs response validation in CI and staging, with the gateway enforcing coarse rules (content type, size, auth) across all services.
Request validation in Express
A request validator loads the spec once, compiles the schemas (most use Ajv under the hood), and registers middleware that matches the incoming method and path, then validates headers, path params, query, cookies, and the body against the operation's schema:
import express from "express";
import * as OpenApiValidator from "express-openapi-validator";
const app = express();
app.use(express.json());
app.use(
OpenApiValidator.middleware({
apiSpec: "./openapi/openapi.yaml",
validateRequests: {
allowUnknownQueryParameters: false,
coerceTypes: true,
},
validateResponses: process.env.NODE_ENV !== "production",
}),
);
app.post("/users", (req, res) => {
// req.body is already validated against CreateUser here.
res.status(201).json(createUser(req.body));
});
app.use((err, _req, res, _next) => {
// The validator throws a structured 400; map it to your error format.
res.status(err.status ?? 500).json({
type: "https://example.com/errors/validation",
title: "Request validation failed",
status: err.status ?? 500,
errors: err.errors ?? [],
});
});
A request that omits a required field or sends a string where the schema declares an integer is rejected before the handler runs, with an errors array naming the exact JSON pointer, the failed keyword, and the received value. That consistency is the big win: every endpoint returns the same field-level error shape instead of each handler inventing its own validation.
Fastify and framework-native options
Fastify validators either integrate an OpenAPI middleware or compile JSON Schema directly into routes (the common TypeBox/type-provider pattern). When you already maintain an OpenAPI document, drive validation from it so the schema is not written twice; when the schemas are authored in code, generate the OpenAPI document from them and keep runtime validation on the compiled schemas. Either way, the rule is one source of truth, never hand-maintain parallel validator schemas that drift from the published contract. openapi-backend goes further and uses the spec for routing as well, which is a clean fit for new services that want the document to be fully authoritative.
Response validation: your safety net outbound
Validating responses checks that what your handler returns matches what the spec promises: a field declared type: integer is not coming back as a string, a required field is not undefined, an enum is not an undocumented value. This catches the bugs that break generated clients even though the server returned 200.
Run response validation:
- always in CI and staging, ideally with contract tests and a validating mock proxy;
- in development, to catch drift the moment a handler changes;
- selectively in production on critical endpoints or a sampled basis, because serializing and checking every response has a cost and a failing response validator needs a policy (you usually log and alert rather than rewrite the body).
Never return a 500 to a customer for a response-validation failure in production; the request may have succeeded. Log it, emit a metric, and fix the code or the spec. Response validation is a drift detector, not a request gate.
Fail open versus fail closed
The central policy decision is what request validation does when a request does not match:
- Fail closed (reject with 400) for strict, machine-facing contracts: internal APIs, financial endpoints, and any route where unknown fields could be confused for real ones. This is the safe default for new services.
- Fail open (allow, but log) during a migration or for public ingestion endpoints where rejecting unknown fields would break already-shipped clients that send extra data. Pair it with telemetry so you can see the mismatch and either update the spec or the clients.
A pragmatic rollout starts fail-open with metrics for a legacy service, watches what actually arrives, reconciles the spec to cover legitimate fields, then flips to fail-closed once the mismatch rate is at zero. Flipping closed without that observation period is how a validator starts rejecting real traffic the spec simply failed to document.
Three related choices:
-
Unknown fields. Reject (
additionalProperties: false) for closed internal DTOs; allow and strip for tolerant public boundaries, but strip deliberately and document it, because silently dropping a field a client thought was saved is its own bug. -
Type coercion.
coerceTypesturns"42"into42for query parameters (which always arrive as strings). Convenient, but it can hide clients sending the wrong type; prefer documenting integer query params and coercing consistently rather than accepting arbitrary malformed input. -
Formats.
format: email,date-time,uuidare only enforced if the validator has format checkers enabled; JSON Schema treats formats as annotations by default. Turn on the checkers you actually mean to enforce.
Performance and operations
- Validators compile schemas to optimized functions once at startup; per-request cost is a single compiled check, which is negligible compared to a database call for typical payloads. Avoid recompiling per request.
- Large multipart uploads and streaming bodies should not be fully buffered just to validate; validate headers and metadata up front and the parts you parse, and skip deep validation of opaque file bytes.
- Expose a metric for validation failures by operation and keyword. A sudden spike in
requiredfailures on one route is an early signal of a broken client release or a spec that drifted. - Keep the loaded spec identical to the one you publish and generate clients from, ideally built and versioned in CI; a validator running a stale document enforces yesterday's contract.
How this relates to mocks and contract tests
Runtime validation, mocks, and contract tests all read the same spec but answer different questions: the middleware rejects malformed live input, a spec-driven mock returns contract-valid responses for frontend work, and contract tests assert that the real service honors the document across its responses. Used together they close the loop in both directions, the client cannot send garbage and the server cannot return garbage. A gateway or Prism proxy can also validate external traffic against a service you cannot modify, which is the fastest way to bring a legacy API under a contract without rewriting it.
Checklist
- Load and compile the OpenAPI document once; validate headers, params, query, and body in middleware or at the gateway.
- Return one consistent field-level 400 shape (reuse Problem Details) with JSON pointers and the failed keyword.
- Keep one source of truth; never hand-maintain parallel validator schemas.
- Validate responses in dev, CI, and staging, and on a sampled or critical basis in production, logging drift rather than failing successful requests.
- Choose fail-open versus fail-closed deliberately; migrate legacy services open-with-metrics first, then closed.
- Decide unknown-field, coercion, and format-checker policies explicitly instead of taking defaults.
- Compile once, avoid buffering large uploads, and emit per-operation validation-failure metrics.
- Keep the enforced spec identical to the published, client-generating document.
Get these in place and your OpenAPI document stops being a picture of the API and becomes the thing the API actually obeys, in both directions.
You can wire request and response validation to a live spec, generate a conformant client, and run contract checks against a mock in one local-first workspace, right in your browser. For the CI-side counterpart that proves the real service honors the document, see API contract testing without Pact's overhead.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.