Combining middlewareMiddleware · Hono

Build one guard out of several with the combinators in hono/combine: accept either of two credentials with some(), require several checks with every(), and carve out public routes with except().

What you will learn

Read the theory for Combining middleware

All Middleware lessons

All Hono courses

loading types…

What you'll learn

  • Accept a bearer token or basic credentials on the same routes with some(), and know whose challenge a refused request sees
  • Chain checks that must all pass with every(), in order
  • Exclude paths from a guard with except(), by pattern or by a function of the context

Combining middleware

Eight lessons have added eight guards, each a single app.use(). Real APIs need combinations: a route that accepts a token or a password, a set of checks that must all hold, a rule that applies everywhere except the health check and the public listing. Written by hand those are nested conditionals inside a custom middleware, and they are easy to get subtly wrong, calling next() twice or not at all. hono/combine gives three functions that compose middleware into one, and this lesson uses all three on the cats API.

some(): the first that accepts

import { some } from 'hono/combine';

app.use('/api/*', some(bearerAuth({ token }), basicAuth({ username, password })));

some(a, b, …) runs a; if it throws, the way every auth middleware refuses, b runs on the same request, and so on. The first middleware that lets the request through ends the search. When every one refuses, the error of the last one is what the client sees, so some(bearerAuth(…), basicAuth(…)) refuses with WWW-Authenticate: Basic …, which a browser turns into a login prompt, while the reverse order refuses with the bearer challenge. Order the alternatives by which refusal you want a client without credentials to meet.

A middleware inside some() must refuse by throwing or by setting c.res, as the built-ins do. One that merely returns a Response is not treated as a refusal and its response is dropped, which ends as a 500.

every(): all of them, in order

import { every } from 'hono/combine';

app.use('/api/*', every(requestId(), rateLimit(), bearerAuth({ token })));

every(a, b, …) runs each in turn, exactly as registering them one after another would, and stops at the first that refuses. It exists for the composition: an every() can be one branch of a some(), and the result is one middleware to register or export.

except(): everywhere but here

import { except } from 'hono/combine';

app.use('/api/*', except('/api/public/*', bearerAuth({ token })));
app.use('*', except(['/health', '/public/*'], bearerAuth({ token })));
app.use('*', except((c) => c.req.method === 'GET', bearerAuth({ token })));

except(condition, mw…) skips its middleware when the condition holds. A string is a route pattern, an array is several, and a function of the context decides anything else, such as letting every GET through unguarded. Underneath it is some(condition, every(mw…)): the condition as a middleware that passes when it matches, and the rest when it does not.

Composing

Combinators nest. The guard for the cats API reads inside out: either credential, some(bearer, basic); with a request id first, every(requestId(), guard); except the public routes, except('/api/public/*', …); mounted on /api/*. One app.use() line, one place to read what protects the API, and one thing to export and reuse in another app.

Your task

Everything under /api is open.

  1. Build one guard that accepts either the bearer token TOKEN or the basic credentials admin / secret in the realm Cats, ordered so a request with no credentials sees the Basic challenge.
  2. Combine it with requestId() so every guarded request gets an id first.
  3. Mount the combination on /api/*, excluding /api/public/*.
  4. GET /api/cats answers { "requestId", "cats" }.

Send GET /api/cats from the request bar with no header, then with Authorization: Bearer cat-token, then with Authorization: Basic YWRtaW46c2VjcmV0.

When it fails

  • A request with no credentials sees Bearer realm="": the alternatives are in the other order. The last one's refusal is the one shown.
  • /api/public/cats asks for credentials: except() is missing, or its pattern lacks the /*.
  • requestId is undefined in the response: requestId() is outside the combination, or after the guard in every(), so it never ran on a refused request; put it first.
  • Every guarded request answers 500 and the console says Context is not finalized: a custom middleware inside some() returned a response instead of throwing. The built-ins throw; a custom guard should throw new HTTPException(401, …).

Remember

  • some(a, b) accepts with the first that passes and refuses with the last one's error.
  • every(a, b) runs all, in order, stopping at the first refusal.
  • except(pattern | patterns | fn, mw) skips the middleware where the condition holds.
  • Compose inside out into one middleware; guards inside some() must throw to refuse.
Stuck? Show a hint

some(a, b) runs a and, if it throws, b; every(a, b) runs both. except('/api/public/*', mw) skips mw for matching paths. Compose from the inside out: the guard is some(bearerAuth(…), basicAuth(…)); every(requestId(), guard) adds the id; except(…) wraps the whole thing; app.use('/api/*', …) mounts it.