Blog

Nothing reads unvalidated input

2026-07-29

An isometric illustration of a bolted pipe flange with a spoked valve wheel on top

It is late, the fix is small, and the value you need is right there on the request. You reach for c.req.json(), pull the field out, and move on. You will add validation properly tomorrow.

Every route in the kit declares two things on the route line: what it accepts, and how the caller is verified. Both are mandatory. The point of putting them there is that reading a route tells you what it takes and who may call it without opening the handler.

app.post("/entries", requireBearer, zValidator("json", NewEntry, validationHook), handler)

That is a convention, and conventions decay at exactly the moment described above. The interesting work is what makes it structural instead.

Gate one: make the raw input unreadable

flowchart TD
    I["Request input"] --> Q["c.req.query()"]
    I --> J["c.req.json()"]
    I --> P["c.req.param()"]
    I --> V["c.req.valid(target)"]
    Q --> X1["Banned by a lint rule"]
    J --> X1
    P --> X1
    V --> G2{"Did a validator<br/>declare that target?"}
    G2 -- no --> X2["The probe catches it —<br/>there is no accessor to ban"]
    G2 -- yes --> H["Your handler,<br/>with typed values"]

Hono, the router underneath, gives your handler c.req.query(), c.req.json() and c.req.param(). Any of them hands you request input with nothing between it and your code.

So a Biome plugin removes them. no-raw-request-input.grit is scoped to route code and flags all three, which leaves c.req.valid(target) as the only remaining way to reach request input — and c.req.valid() does not exist until a validator declared it.

The comment at the top of the plugin states the goal precisely:

with them gone, c.req.valid() is the ONLY way to reach request input, so an unvalidated query or body is not merely discouraged — it is unreadable.

That is a different kind of guarantee from a code review note. You cannot forget it at 5pm, and a new contributor cannot fail to know about it.

What is deliberately left alone

A ban that is too wide gets disabled, so three things stay reachable and each has a reason written next to it:

  • c.req.header(...) — read by the CSRF and bearer paths, which have no schema to declare. A header is not a body.
  • c.req.raw — how the auth catch-all hands the untouched request to Better Auth. Parsing it first would defeat the point.
  • c.req.url — routing, not input.

Those exceptions keep the rule alive. A gate everyone has a legitimate reason to work around stops being a gate.

Gate two: the one input a ban cannot cover

Path params are different, and the difference is subtle.

Banning c.req.param() stops you reading a param raw. It does not stop you declaring a route with :userId in it and then reading that param through c.req.valid("param") belonging to some validator you never wrote. The value arrives validated against nothing, and there is no accessor to ban — the one being used is the correct one.

So params need the opposite of a ban: a positive check that every route declaring a :segment also registered a validator for it. That check needs the finished route table, which only exists after every capability’s routes have been applied — nothing static can see it, but a composed Hono app can, because app.routes carries one entry per registered handler with the path pattern intact.

Which leaves one problem: identifying which of a route’s middlewares is the param validator.

The probe

@hono/zod-validator returns the same anonymous closure whatever its target. No name, no property, no distinguishing shape. A param validator and a json validator are indistinguishable to anything that inspects them.

What does differ is behavior. hono/validator reads c.req.param() for the param target and nothing else does.

So the check calls each middleware once against a fake context that answers every accessor the library might reach for, and records the one that gives it away:

const req = {
  param: () => { record(); return {}; },
  queries: () => ({}),
  query: () => ({}),
  header: (name) => (name === undefined ? {} : undefined),
  json: async () => ({}),
  // …
};

The probe context is deliberately threadbare. Anything a real guard would read is absent, so a guard meeting it rejects immediately rather than doing work.

It is also why only middleware are probed and never the terminal handler. Calling a middleware with a stub next is cheap and inert; calling the handler would be running the route.

The output is a list of routes that declare a :segment and registered no validator for it. An empty array is the contract being met.

Why the hook is not a wrapper

One smaller decision is worth pulling out, because the tempting version is wrong.

Every provided route passes the same validationHook to zValidator. Its only job is to map Zod’s error through fromZodError, so a malformed request produces the same validation/invalid_input 400, with the same issues[], that every other failure in the app produces. Without it, @hono/zod-validator answers with its own JSON body — which is neither a PithyError payload nor routed through the error handler, so one class of failure looks different from all the others for no reason a caller could guess.

The obvious tidy-up is to wrap zValidator in a helper that supplies the hook. It was rejected twice over: a wrapper is a second name for a library we deliberately do not re-export, and it hides the target from the route line — which is the one thing this whole arrangement exists to make visible.

The other half of the line

Validation has a sibling. VerificationStrategy declares how the caller is proven — bearer, session, signed-webhook and the rest — and every route declares one. There is no implicit auth, and no default that quietly applies when a route forgets.

Together they make a route line a complete statement of its contract: this is the shape I accept, and this is who may send it. Everything above exists so that the statement cannot be a lie — including at 5pm.