Portreeve
how-to · 7 Sept 202612 min read

Hono middleware: the onion model, with a screening example

How Hono middleware works: what await next() does to the response, where per-request state lives, why a Map counter lies on Workers, and safe timeouts.

By the end of this you will have four pieces of Hono middleware on one app: a logger that measures your handler, an auth check that returns 401 without touching the route, a rate limiter whose counter survives more than one request, and a screening middleware that calls an external API with a deadline and hands its answer to the route. Every one of them uses the same (c, next) signature, and almost every bug people hit with it comes from not respecting the shape of that signature.

The shape is an onion. Each middleware wraps everything registered after it. next() is the hole the rest of the stack runs through, and the code after await next() runs on the way back out, with the response already built.

Prerequisites

  • Node 18.17+, Bun, or a Cloudflare Workers project with Wrangler. The code is the same; the notes about isolates and CPU time are Workers-only.
  • Hono 4.x (npm install hono) and TypeScript. The typed-context parts do nothing without it.
  • For step 4 and step 5, a Cloudflare account. Everything else runs under bun run or node --watch.

1. The onion, and what await next() gives back

The Hono middleware guide puts the rule plainly: "The order in which Middleware is executed is determined by the order in which it is registered." What runs before next() runs outside in, what runs after it runs inside out.

So a logger that wants to measure the handler puts its timer around next():

import { Hono } from 'hono'

const app = new Hono()

app.use(async (c, next) => {
  const start = Date.now()
  await next()
  console.log(`${c.req.method} ${c.req.path} ${c.res.status} ${Date.now() - start}ms`)
})

Two things are load-bearing here. The await is one: drop it and c.res is read before the handler has produced anything, and the request often returns a 404 because no route ever ran. The other is that c.res is only meaningful after next() returns. Before that point there is no response to read or mutate.

On Workers that timer is also lying to you in a specific way. Cloudflare's security model docs state it as a deliberate Spectre mitigation: "Date.now() returns the time of the last I/O. It does not advance during code execution." Your logger measures I/O boundaries, not CPU. For a handler that hits a database or a fetch, that number is still useful. For a handler that only does arithmetic, it will read as zero.

Mutating the response on the way out is fine for headers:

app.use(async (c, next) => {
  await next()
  c.res.headers.set('x-request-id', crypto.randomUUID())
})

Replacing it wholesale is where people get stuck. TorbjornHoltmon opened hono#960 about exactly this while proxying from a Worker: "When you proxy a request from a worker, the response that Hono gets is a fetch response, and you cannot change headers." A Response that came back from fetch has immutable headers. If you need to change them, build a new one from the old body and assign it: c.res = new Response(c.res.body, { status: c.res.status, headers }).

2. Carrying state down the chain

Do not reach for a module-level variable to pass data from middleware to handler. Use c.set and c.get, which the context docs describe as key-value pairs "with a lifetime of the current request", and declare the keys so the handler gets types:

type Variables = {
  userId: string
}

const app = new Hono<{ Variables: Variables }>()

app.get('/api/me', (c) => c.json({ id: c.get('userId') }))

c.get('userId') is now string rather than any, and a typo in the key is a compile error. The types stop there, though. Nothing sets userId yet, and if the middleware that does gets registered after this route or scoped to a path the route does not match, c.get('userId') is undefined at runtime and the compiler still says string. Registration order is part of the contract, not a detail.

3. A custom Hono middleware for auth

Write it with createMiddleware from hono/factory so the generics travel with the function instead of being repeated at every registration site.

import { createMiddleware } from 'hono/factory'

type Env = {
  Bindings: { API_KEYS: KVNamespace }
  Variables: { userId: string }
}

export const auth = createMiddleware<Env>(async (c, next) => {
  const header = c.req.header('authorization')
  if (!header?.startsWith('Bearer ')) {
    return c.json({ error: 'missing_token' }, 401)
  }

  const userId = await c.env.API_KEYS.get(header.slice(7))
  if (!userId) {
    return c.json({ error: 'invalid_token' }, 401)
  }

  c.set('userId', userId)
  await next()
})

app.use('/api/*', auth)

Returning a Response and never calling next() is how you halt; the docs say a middleware should either "await next() and return nothing to call the next Middleware, or return a Response to early-exit". There is no sentinel value and no cleanup call. The rest of the onion simply does not run.

Scope it to /api/*, not *. A * registration also runs on your health check, your favicon, and your webhook receiver, and an auth middleware that rejects your own uptime monitor is a self-inflicted incident.

4. Rate limiting, and where the counter lives

This is the version in most tutorials:

const hits = new Map<string, number[]>()   // do not ship this on Workers

It works perfectly under bun run, because there is one process and one Map. On Cloudflare it quietly does nothing useful. Workers run in V8 isolates, and Cloudflare's how Workers works page says "there is no guarantee that any two user requests will be routed to the same or a different instance of your Worker" and follows it with "Cloudflare recommends you do not use or mutate global state." Your counter is per isolate, isolates are created and evicted constantly, and an attacker gets a fresh budget every time they land on a new one.

KV is the next thing people try, and it is worse for this. Workers KV "has a maximum of 1 write to the same key per second", per the KV write docs, and exceeding that throws 429s; a value can also take up to 60 seconds to become visible in other parts of the world. A counter is a hot key by definition.

The honest options are a Durable Object, which gives you one strongly consistent counter per key, or Cloudflare's rate limiting binding, which is cheap and approximate. Cloudflare is direct about the tradeoff: the API is "permissive, eventually consistent, and intentionally designed to not be used as an accurate accounting system", and for each unique key there is a separate limit per Cloudflare location.

type Env = { Bindings: { RATE_LIMITER: RateLimit } }

export const rateLimit = createMiddleware<Env>(async (c, next) => {
  const key = c.req.header('cf-connecting-ip') ?? 'unknown'
  const { success } = await c.env.RATE_LIMITER.limit({ key })
  if (!success) {
    return c.json({ error: 'rate_limited' }, 429, { 'retry-after': '60' })
  }
  await next()
})

Key on cf-connecting-ip, not x-forwarded-for. Cloudflare's HTTP headers reference tells origins to read CF-Connecting-IP instead, because it has "a consistent format containing only one IP address", while x-forwarded-for arrives as a comma-separated list whose leading entries came from the client.

Even with the right key, keep the expectations low. One office or one mobile carrier NAT is thousands of people behind one address, so a per-IP counter is a coarse signal and not a per-user budget. The velocity checks that hold up are keyed on a device or a card.

5. Hono middleware on Cloudflare Workers

c.env is where bindings live, and it exists only inside a request. Reading a binding at module scope gets you undefined on Workers, so any client that needs a secret has to be constructed inside the middleware body.

There are no Node globals. No process.env, no Buffer, no crypto.createHmac from node:crypto unless you enable nodejs_compat. Use Web Crypto (crypto.subtle) and TextEncoder instead.

Watch CPU time. The Workers limits page gives the free plan 10 ms of CPU per HTTP request and 128 MB of memory per isolate, shared across every request that isolate is handling. Waiting on fetch is not CPU, so a slow upstream will not blow that budget, but parsing a large JSON body in three separate middlewares can.

For anything you want to happen without the user waiting, hand it to waitUntil on the way out:

app.use(async (c, next) => {
  await next()
  c.executionCtx.waitUntil(recordAudit(c.req.path, c.res.status))
})

6. Screening: a network call with a deadline

Now the case that exercises the whole model. You want to check each signup against an external abuse API before your handler creates the account.

The failure mode to design against first: a synchronous third-party call in the request path means your p99 is now their p99. Give it a deadline with AbortSignal.timeout(), which "aborts with a TimeoutError DOMException on timeout", and decide in advance what a timeout means. For an abuse check on signup, it means allow. Losing signups because a screening API is having a bad afternoon is worse than admitting a few abusers you will catch on the next signal.

type Verdict = {
  verdict: 'allow' | 'review' | 'block'
  reasons: string[]
  degraded?: boolean
  id?: string
}

type Env = {
  Bindings: { SCREENING_URL: string; SCREENING_KEY: string }
  Variables: { verdict: Verdict; body: { email: string } }
}

export const screen = createMiddleware<Env>(async (c, next) => {
  const body = await c.req.json<{ email: string }>()
  let verdict: Verdict = {
    verdict: 'allow',
    reasons: ['screening_failopen'],
    degraded: true,
  }

  try {
    const res = await fetch(c.env.SCREENING_URL, {
      method: 'POST',
      headers: {
        authorization: `Bearer ${c.env.SCREENING_KEY}`,
        'content-type': 'application/json',
      },
      body: JSON.stringify({
        event_type: 'signup',
        email: body.email,
        ip: c.req.header('cf-connecting-ip'),
      }),
      signal: AbortSignal.timeout(400),
    })
    if (res.ok) verdict = (await res.json()) as Verdict
  } catch (err) {
    console.warn('screening degraded', (err as Error).name)
  }

  c.set('body', body)
  c.set('verdict', verdict)
  await next()
})

That shape is what I built Portreeve to sit on the other end of: one call returns allow, review, or block with reason codes in under 100 ms, and it fails open by default inside a 400 ms budget, returning allow with degraded: true and a *_failopen reason code when it cannot answer. The Node SDK targets Node 18.17+, so from Hono on Workers you make the same call over the REST endpoint, POST https://api.portreeve.com/v1/verdict with a bearer sk_ key, which is the fetch above with a different URL.

The middleware does not decide the response. The route does, because only the route knows what a block means for its own contract:

app.post('/api/signup', screen, async (c) => {
  const v = c.get('verdict')
  if (v.verdict === 'block') {
    return c.json({ error: 'signup_unavailable' }, 403)
  }

  const user = await createUser(c.get('body'))
  if (v.verdict === 'review') {
    c.executionCtx.waitUntil(flagForReview(user.id, v.id))
  }
  return c.json({ id: user.id }, 201)
})

A block on /api/signup is a generic 403. On a checkout route the same verdict might be a decline you already have copy for; on a login route it might be a step-up challenge instead of a rejection. Handling verdicts is a per-route decision, and pushing it into shared middleware is how you end up with an abuse check that returns 403 to your own mobile client's token refresh. The Express version of this middleware makes the same split with req instead of c.

Note the c.set('body', body) line. The middleware consumed the request stream; handing the parsed object down is cheaper and safer than hoping the handler can read it again.

7. Verify it

Run wrangler dev (or bun run --hot src/index.ts, which serves on 3000 instead of 8787) and fire a signup:

curl -i localhost:8787/api/signup \
  -H 'content-type: application/json' \
  -d '{"email":"[email protected]"}'

You should see a 201, and one log line from step 1 with a status and a duration. Then point SCREENING_URL at something that never answers, such as https://httpbin.org/delay/10, and fire the same request. You should get the same 201 in a little over 400 ms, plus screening degraded TimeoutError in the logs.

That second test is the one worth keeping. A fail-open path nobody has ever exercised is a fail-closed path with optimistic comments on it.

8. Order the stack

Registration order is the whole configuration, so make it deliberate: cheap and local first, then auth, then anything on the network.

app.use(logger)               // no I/O
app.use('/api/*', auth)       // one KV read, rejects most junk
app.use('/api/*', rateLimit)  // one binding call
app.post('/api/signup', screen, signupHandler)   // route-scoped network call

Auth before rate limiting means unauthenticated garbage never reaches the limiter. The screening call is registered on one route rather than app.use('*'), so your health check does not pay for it.

For production

Replace the timeout guess with a number. 400 ms is a starting point, not a measurement: log the duration of every screening call for a week, then set the deadline near your observed p99 and alert on the degraded rate.

Write the failure mode down next to the code. Every network call in the chain should have a comment naming what happens when it times out and who is on the hook when that path fires more than it should.

Of the five mistakes above, the first two fail loudly in development: a missing await on next(), and a module-level Map that stops counting the moment you deploy. Registering everything on '*', awaiting a fetch with no signal, and returning the user-facing error from middleware instead of letting the route choose all fail somewhere else, under load, on the route you care about most.

If you want the screening half without building the engine behind it, start on the free tier: 1,000 screened events a month, no card, test mode fully isolated from live.

← Back to all posts