Guide/guides

Middleware Guide

Middleware runs code around a request: before the controller action, after it, or instead of it. Guren uses Hono's middleware model, and you attach middleware in one of three places:

Middleware Guide

Middleware runs code around a request: before the controller action, after it, or instead of it. Guren uses Hono's middleware model, and you attach middleware in one of three places:

Where Applies to Section
The application, with app.use() Every request Global Middleware
A route or route group, with .middleware() Those routes Route Middleware
A name, with aliasMiddleware() / groupMiddleware() Routes that use the name Middleware Aliases, Middleware Groups

An app created with createApp({ auth }) already mounts the session and CSRF middleware, and every app mounts the auth context (which requireAuthenticated reads); see Built-in Helpers.

Global Middleware

// src/app.ts
import { createApp, defineMiddleware } from '@guren/core'

const requestTimer = defineMiddleware(async (ctx, next) => {
  const started = performance.now()
  await next()
  const duration = Math.round(performance.now() - started)
  console.log(`${ctx.req.method} ${ctx.req.path} -> ${ctx.res.status} (${duration}ms)`)
})

const app = createApp()
app.use('*', requestTimer)

Global middleware has to be attached before the routes are mounted. A service provider therefore calls app.use() from register(), not boot(): routes are mounted between the two, and middleware added in boot() never runs for them. Architecture shows the provider form.

Route Middleware

import { Router, requireAuthenticated } from '@guren/core'
import DashboardController from '@/app/Http/Controllers/DashboardController'

export function registerWebRoutes(router: Router): void {
  router.get('/dashboard', [DashboardController, 'index']).middleware(
    requireAuthenticated({ redirectTo: '/login' }),
  )
}

Route middleware applies only to that route, or to every route inside a group. .middleware() accepts handler functions, registered alias names, or a mix of both. Mixing the two changes the order they run in; see Execution Order.

Middleware Aliases

Register a short name for a middleware function, then refer to it by name in your route files without importing the function each time. requireAdmin here is middleware the app defines itself (see Writing Custom Middleware):

import { Router, requireAuthenticated } from '@guren/core'
import { requireAdmin } from '@/app/Http/middleware/admin'

export function registerWebRoutes(baseRouter: Router): void {
  const router = baseRouter
    .aliasMiddleware('auth', requireAuthenticated())
    .aliasMiddleware('admin', requireAdmin())
}

rule

aliasMiddleware() returns a new Router type carrying the alias name it just registered. Call it without capturing the result and the name never reaches the type, so a later .middleware('auth') fails to compile. Always chain and assign, as above.

The type also travels across functions. See Registering Aliases.

Once registered, use the alias anywhere middleware is accepted:

router.get('/dashboard', [DashboardController, 'index']).middleware('auth')
router.post('/settings', [SettingsController, 'update']).middleware('auth', 'admin')

Aliases also make middleware visible to guren audit, which reports a route's middleware by name. It recognizes the framework's guards and authorization middleware either way, but your own middleware passed as a bare function is invisible to it.

Middleware Groups

A group bundles several aliases under one name, for stacks that run together. Every member must be a registered alias or another group:

const router = baseRouter
  .aliasMiddleware('auth', requireAuthenticated())
  .aliasMiddleware('admin', requireAdmin())
  .aliasMiddleware('throttle', createRateLimitMiddleware({ limit: 60, windowMs: 60_000 }))
  .groupMiddleware('backoffice', ['auth', 'admin'])
  .groupMiddleware('api', ['throttle'])

Apply a group to a set of routes with router.middleware():

router.middleware('backoffice').group((backoffice) => {
  backoffice.get('/admin', [AdminController, 'index'])
  backoffice.get('/admin/users', [AdminUserController, 'index'])
})

router.middleware('api').group((api) => {
  api.get('/api/posts', [ApiPostController, 'index'])
  api.post('/api/posts', [ApiPostController, 'store'])
})

Groups and individual aliases combine:

router.middleware('api', 'auth').group((group) => {
  group.get('/api/me', [ProfileController, 'show'])
  group.put('/api/me', [ProfileController, 'update'])
})

Execution Order

A route's middleware runs in this order:

  1. Every alias or group name in the route's chain, outer groups first.
  2. Every inline handler function, outer groups first.
  3. Validation of the route's body, params and query schemas.
  4. The controller action.

Names run before functions across groups as well as within one .middleware() call. So an inline handler on an outer group runs after a name on an inner group, the reverse of how the code reads:

flowchart LR
  subgraph Written["As written"]
    direction TB
    W1["outer group<br/>.middleware(inlineA)"]
    W2["inner group<br/>.middleware('auth')"]
    W3["route<br/>.middleware(inlineB)"]
    W1 --> W2 --> W3
  end
  subgraph Actual["As it runs"]
    direction TB
    A1["'auth'<br/>every name comes first"]
    A2["inlineA<br/>outer group's inline handler"]
    A3["inlineB<br/>the route's inline handler"]
    A4["route contract validation<br/>body / params / query schemas"]
    A5["controller action"]
    A1 --> A2 --> A3 --> A4 --> A5
  end
  Written --> Actual

When relative order matters, register every piece as an alias. On a controller action, the last two steps cannot be reordered: a schema attached to the route is validated after all middleware, immediately before the action.

Built-in Helpers

Helper What it does Mounted for you
defineMiddleware Types a Hono middleware function for Guren —
createSessionMiddleware Attaches a session to each request With createApp({ auth })
createCsrfMiddleware Checks the CSRF token on state-changing requests With createApp({ auth })
attachAuthContext Puts the auth context on each request Always
requireAuthenticated / requireGuest Let only signed-in users / only guests through No: add them to routes
createRateLimitMiddleware Limits requests per client No: add it to routes

To opt out of the automatic session or CSRF middleware, pass auth: { autoSession: false } or auth: { autoCsrf: false }.

createSessionMiddleware

Attaches a session to the request context. The session id travels in a signed cookie, and the data is kept in memory (MemorySessionStore) unless you pass a store. Mount it yourself only in an app without the auth option:

import { createSessionMiddleware } from '@guren/core'

app.use('*', createSessionMiddleware())

Read the session with getSessionFromContext(ctx) or ctx.get('guren:session').

store accepts a SessionStore or a function returning one. The function runs on every request, so a store that needs a runtime binding (Workers) or a connection (Redis) is not built at boot; memoize it yourself if building is expensive. To declare several stores and pick one per environment, use a SessionManager (see Authentication).

Auth Guards

requireAuthenticated and requireGuest read the auth context that the application attaches to every request, so you only add the guards:

import { Router, requireAuthenticated, requireGuest } from '@guren/core'

const router = new Router()
  .aliasMiddleware('auth', requireAuthenticated({ redirectTo: '/login' }))
  .aliasMiddleware('guest', requireGuest({ redirectTo: '/dashboard' }))

router.middleware('auth').group((auth) => {
  auth.get('/settings', [SettingsController, 'index'])
  auth.get('/dashboard', [DashboardController, 'index'])
})

redirectTo applies to browsers. An agent tool call (see the Agent interface) cannot follow a redirect, so both guards answer it with a JSON refusal instead: 401 from requireAuthenticated, 403 from requireGuest, or the status you pass. A responseFactory set beside redirectTo is skipped for those calls too.

CSRF Protection

The CSRF middleware checks the token on state-changing requests (POST, PUT, PATCH, DELETE). createApp({ auth }) mounts it for every request, so add it by hand only in an app without the auth option:

const router = new Router().aliasMiddleware('csrf', createCsrfMiddleware())

router.middleware('csrf').group((csrf) => {
  csrf.post('/posts', [PostsController, 'store'])
})

CSRF Protection covers tokens in forms and requests.

Rate Limiting

Apply rate limiting to routes or groups:

import { createRateLimitMiddleware } from '@guren/core'

const router = new Router().aliasMiddleware('throttle', createRateLimitMiddleware({ limit: 60, windowMs: 60_000 }))

router.middleware('throttle').group((throttle) => {
  throttle.post('/api/login', [AuthController, 'login'])
})

Rate Limiting covers keys, stores and response headers.

Writing Custom Middleware

Create middleware with defineMiddleware. The signed-in user is read through the auth context:

import { AUTH_CONTEXT_KEY, defineMiddleware } from '@guren/core'
import type { AuthContext } from '@guren/core'

export const requireSubscription = defineMiddleware(async (ctx, next) => {
  const auth = ctx.get(AUTH_CONTEXT_KEY) as AuthContext | undefined
  const user = await auth?.user<{ isSubscribed: boolean }>()

  if (!user?.isSubscribed) {
    return ctx.json({ error: 'Subscription required' }, 403)
  }

  await next()
})

Save it under app/Http/middleware/ and register it as an alias to use it by name:

import { Router } from '@guren/core'
import { requireSubscription } from '@/app/Http/middleware/subscription'

const router = new Router().aliasMiddleware('subscribed', requireSubscription)

router.middleware('subscribed').group((group) => {
  group.get('/premium', [PremiumController, 'index'])
})