Skip to content

Hono — Middleware

OWNER: urszula, maxim ALSO_USED_BY: sandro, boris, yoanna LAST_VERIFIED: 2026-03-26 GE_STACK_VERSION: 4.12.x


Overview

GE middleware stack for Hono services: authentication, CORS, rate limiting, logging, error handling. All middleware is typed, composable, and tested in isolation. Agents building or modifying API middleware need this page.


GE Middleware Stack (Standard Order)

Every GE Hono service applies middleware in this exact order:

import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { logger } from 'hono/logger'
import { AppEnv } from './types/env'
import { requestIdMiddleware } from './middleware/request-id'
import { errorHandler } from './middleware/error-handler'
import { rateLimiter } from './middleware/rate-limiter'
import { authMiddleware } from './middleware/auth'

const app = new Hono<AppEnv>()

app.use('*', requestIdMiddleware)       // 1. Request ID
app.onError(errorHandler)               // 2. Error handler
app.use('*', cors(corsConfig))          // 3. CORS
app.use('*', logger())                  // 4. Logging
app.use('/api/*', rateLimiter)          // 5. Rate limiting
app.use('/api/*', authMiddleware)       // 6. Authentication

CHECK: Middleware is registered in the order above. IF: Auth runs before error handler. THEN: Auth errors bypass the error handler. Wrong order.


Request ID Middleware

Every request gets a unique ID for tracing across services.

// src/middleware/request-id.ts
import { createMiddleware } from 'hono/factory'
import { AppEnv } from '../types/env'

export const requestIdMiddleware = createMiddleware<AppEnv>(
  async (c, next) => {
    const requestId = c.req.header('X-Request-ID') ?? crypto.randomUUID()
    c.set('requestId', requestId)
    c.header('X-Request-ID', requestId)
    await next()
  }
)

CHECK: Incoming X-Request-ID is preserved if present. THEN: This enables cross-service tracing in k3s.


Error Handler

GE uses app.onError() for centralized error handling. NOT try/catch in middleware.

// src/middleware/error-handler.ts
import { HTTPException } from 'hono/http-exception'
import { ZodError } from 'zod'
import { ErrorHandler } from 'hono'
import { AppEnv } from '../types/env'

export const errorHandler: ErrorHandler<AppEnv> = (err, c) => {
  const requestId = c.get('requestId') ?? 'unknown'

  // Hono HTTP exceptions (thrown intentionally)
  if (err instanceof HTTPException) {
    return c.json({
      error: 'HTTP_ERROR',
      message: err.message,
      requestId,
    }, err.status)
  }

  // Zod validation errors (should not reach here if using zValidator)
  if (err instanceof ZodError) {
    return c.json({
      error: 'VALIDATION_ERROR',
      issues: err.issues,
      requestId,
    }, 422)
  }

  // Unexpected errors — log full stack, return generic message
  console.error(`[${requestId}] Unhandled error:`, err)
  return c.json({
    error: 'INTERNAL_ERROR',
    message: 'An unexpected error occurred',
    requestId,
  }, 500)
}

CHECK: The error handler uses app.onError(), NOT middleware wrapping await next(). IF: You see try { await next() } catch(e) { ... } in middleware. THEN: Replace with app.onError(). Middleware try/catch is unreliable in Hono.

CHECK: The error handler checks instanceof HTTPException BEFORE instanceof Error. IF: Order is reversed. THEN: HTTPException is caught by the generic Error branch. Wrong status code returned.

CHECK: Zod errors are checked via .issues, NOT .errors. IF: Using .errors property. THEN: This is Zod v4. The property is .issues.


Authentication Middleware

GE services authenticate via bearer tokens verified against the admin-ui auth API or via internal service tokens for inter-service communication.

// src/middleware/auth.ts
import { createMiddleware } from 'hono/factory'
import { HTTPException } from 'hono/http-exception'
import { AppEnv } from '../types/env'

export const authMiddleware = createMiddleware<AppEnv>(
  async (c, next) => {
    const authHeader = c.req.header('Authorization')

    if (!authHeader?.startsWith('Bearer ')) {
      throw new HTTPException(401, { message: 'Missing bearer token' })
    }

    const token = authHeader.slice(7)

    // Internal service token (for inter-service calls within k3s)
    if (token === process.env.INTERNAL_API_TOKEN) {
      c.set('userId', 'system')
      c.set('agentId', c.req.header('X-Agent-ID') ?? null)
      await next()
      return
    }

    // External token — verify against auth service
    const user = await verifyExternalToken(token)
    if (!user) {
      throw new HTTPException(401, { message: 'Invalid token' })
    }

    c.set('userId', user.id)
    c.set('agentId', null)
    await next()
  }
)

CHECK: Auth middleware throws HTTPException, NOT returns a response directly. THEN: HTTPException flows through the error handler for consistent formatting.

CHECK: Internal service token is checked via constant-time comparison in production. IF: Using === for token comparison. THEN: Acceptable for internal tokens behind k3s network policy. External tokens use the auth service which handles timing-safe comparison.

Skipping Auth for Public Routes

// Health check and OpenAPI spec are public
app.get('/health', (c) => c.json({ status: 'ok' }))
app.get('/openapi.json', openApiHandler)

// Auth applies only to /api/*
app.use('/api/*', authMiddleware)

CHECK: /health is ALWAYS public. k3s probes need it. IF: Auth middleware is on * instead of /api/*. THEN: k3s liveness/readiness probes fail. Pod enters CrashLoopBackOff.


CORS Middleware

import { cors } from 'hono/cors'

const corsConfig = {
  origin: [
    'https://admin.growingeurope.com',
    'http://localhost:3000',
  ],
  allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
  allowHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
  exposeHeaders: ['X-Request-ID', 'X-Response-Time'],
  maxAge: 86400,
  credentials: true,
}

app.use('*', cors(corsConfig))

CHECK: CORS origin is an explicit allowlist, NOT '*'. IF: You set origin: '*'. THEN: Credentials mode breaks (browsers reject * with credentials: true). THEN: Security violation — any domain can call the API.

CHECK: localhost:3000 is only in the list for dev. Remove in production config. THEN: Use environment variable to control allowed origins.


Rate Limiting

GE rate limiting uses in-memory counters per service instance. For distributed rate limiting across replicas, use Redis.

// src/middleware/rate-limiter.ts
import { createMiddleware } from 'hono/factory'
import { HTTPException } from 'hono/http-exception'
import { AppEnv } from '../types/env'

type RateEntry = { count: number; resetAt: number }
const store = new Map<string, RateEntry>()

export function createRateLimiter(opts: {
  windowMs: number
  max: number
  keyFn?: (c: any) => string
}) {
  return createMiddleware<AppEnv>(async (c, next) => {
    const key = opts.keyFn?.(c) ?? c.get('userId') ?? c.req.header('x-forwarded-for') ?? 'anon'
    const now = Date.now()
    const entry = store.get(key)

    if (entry && now < entry.resetAt) {
      if (entry.count >= opts.max) {
        c.header('Retry-After', String(Math.ceil((entry.resetAt - now) / 1000)))
        throw new HTTPException(429, { message: 'Rate limit exceeded' })
      }
      entry.count++
    } else {
      store.set(key, { count: 1, resetAt: now + opts.windowMs })
    }

    await next()
  })
}

export const rateLimiter = createRateLimiter({
  windowMs: 60_000,
  max: 100,
})

CHECK: Rate limiter returns Retry-After header. THEN: Clients and agents can back off correctly.


Logging Middleware

Hono's built-in logger is sufficient for dev. GE production services add structured JSON logging.

// Development
import { logger } from 'hono/logger'
app.use('*', logger())

// Production — structured JSON
import { createMiddleware } from 'hono/factory'

export const structuredLogger = createMiddleware<AppEnv>(
  async (c, next) => {
    const start = Date.now()
    await next()
    const elapsed = Date.now() - start
    const log = {
      ts: new Date().toISOString(),
      method: c.req.method,
      path: c.req.path,
      status: c.res.status,
      elapsed,
      requestId: c.get('requestId'),
      userId: c.get('userId'),
    }
    console.log(JSON.stringify(log))
  }
)

CHECK: Production logging outputs JSON, not human-readable text. THEN: k3s log aggregation parses JSON. Human-readable logs break parsing.


Creating Custom Middleware

With createMiddleware (Preferred)

import { createMiddleware } from 'hono/factory'
import { AppEnv } from '../types/env'

export const timingMiddleware = createMiddleware<AppEnv>(
  async (c, next) => {
    const start = Date.now()
    await next()
    c.header('X-Response-Time', `${Date.now() - start}ms`)
  }
)

Parameterized Middleware

export function requireRole(role: string) {
  return createMiddleware<AppEnv>(async (c, next) => {
    const userId = c.get('userId')
    const userRole = await getUserRole(userId)
    if (userRole !== role) {
      throw new HTTPException(403, { message: `Requires role: ${role}` })
    }
    await next()
  })
}

// Usage
app.delete('/api/projects/:id', requireRole('admin'), deleteHandler)

CHECK: Parameterized middleware returns the result of createMiddleware(). IF: You return a raw async function. THEN: TypeScript loses the AppEnv type. Use createMiddleware<AppEnv>.


Cross-References

READ_ALSO: wiki/docs/stack/hono/index.md READ_ALSO: wiki/docs/stack/hono/patterns.md READ_ALSO: wiki/docs/stack/hono/pitfalls.md READ_ALSO: wiki/docs/stack/hono/checklist.md