Skip to main content

Hono

The Hono middleware, per-route guard, and admin router — edge-safe, with cf-connecting-ip handling and normalised forwarded IPs

@gentleduck/iam/server/hono exposes the same two request gates as the Express wrapper plus a function that binds admin routes onto a router you already own. It imports no Node built-ins and no Hono types, so it runs wherever Hono runs: Node, Bun, Deno, Cloudflare Workers, and Vercel Edge. The shared extraction and audit behaviour lives in generic helpers; this page documents only what Hono adds.

Install


npm i @gentleduck/iam
import { iamAccessMiddleware, iamGuard, iamBindAdminRouter } from '@gentleduck/iam/server/hono'
import type { IamHono } from '@gentleduck/iam/server/hono'

All three runtime exports carry the iam prefix since 5.0.0. accessMiddleware, guard, and bindAdminRouter no longer resolve.

Setup

Build the engine once per isolate

// lib/engine.ts
import { IamEngine } from '@gentleduck/iam/core'
import { IamRedisAdapter } from '@gentleduck/iam/adapters/redis'

export const engine = new IamEngine({ adapter: new IamRedisAdapter({ client }) })

Set the subject id on the context

The default getUserId reads c.get('userId') and nothing else. Populate it from a verified source in an upstream middleware.

app.use('*', async (c, next) => {
  const session = await verifySession(c.req.header('cookie'))
  if (session) c.set('userId', session.userId)
  await next()
})

Mount a gate

app.use('/api/*', iamAccessMiddleware(engine))
// or
app.delete('/posts/:id', iamGuard(engine, 'delete', 'post'), deletePostHandler)

One guarded request

This sequence diagram traces DELETE /posts/42 through a Hono app with the auth middleware, iamGuard, and a downstream handler.

Loading diagram...

await next() sits outside the wrapper's try, deliberately. Hono was once the only one of the five integrations that awaited the downstream handler inside its own try, so a business-logic error thrown by the route came back out of await next(), was caught here, and was reported through this middleware's onError — pre-empting the app's own app.onError and turning every route failure into an authorization-shaped 500. Both iamAccessMiddleware and iamGuard now let a route error past. An evaluation error still reaches onError.

Everything before that — getUserId included — runs inside the try, so a throwing or rejecting extractor reaches this middleware's onError rather than the framework boundary.

iamAccessMiddleware

Blanket middleware. Derives action and resource from the context.

export function iamAccessMiddleware<
  TAction extends string = string,
  TResource extends string = string,
  TRole extends string = string,
  TScope extends string = string,
>(
  engine: IamEngine<TAction, TResource, TRole, TScope>,
  opts?: IamHono.IOptions<TScope>,
): (c: HonoContext, next: HonoNext) => Promise<Response | undefined>
OptionTypeDefaultMeaning
getUserId(c) => string | nullc.get('userId') ?? nullSubject id. Never reads a header.
getResource(c) => IamRequest.IResourcefirst two segments of c.req.path{ type: parts[0] ?? 'root', id: parts[1], attributes: {} }.
getAction(c) => stringiamActionForMethod(c.req.method)Method map from the generic helpers; an unmapped method yields the reserved refusal token, not 'read'.
trustCloudflareHeadersbooleanfalseOpt in to reading cf-connecting-ip into environment.ip. Off, ip stays undefined.
getEnvironment(c) => IamRequest.IEnvironmentthe Hono defaultEnv below{ ip, userAgent, timestamp }.
getScope(c) => TScope | undefinednone scope stays undefinedMulti-tenant scope.
onDenied(c) => Responsec.json({ error: 'Forbidden' }, 403)Runs when engine.can() returns false.
onError(err, c) => Responsec.json({ error: 'Internal server error' }, 500)Runs when an extractor or engine.can() throws. Not for a downstream route error that goes to your app.onError.

The 401 response is fixed at c.json({ error: 'Unauthorized' }, 401) and has no option. resource.attributes is always {}.

app.use(
  '/api/*',
  iamAccessMiddleware(engine, {
    getUserId: (c) => (c.get('userId') as string | undefined) ?? null,
    getScope: (c) => c.req.header('x-org-id'),
    onDenied: (c) => c.json({ error: 'Forbidden', path: c.req.path }, 403),
  }),
)

Resource types come from the URL verbatim, so /posts/42 checks posts, not post, and /posts/search passes id: 'search'. Supply getResource on such routes.

iamGuard

Per-route middleware with a fixed action and resource type. The resource id is c.req.param('id').

export function iamGuard<
  TAction extends string = string,
  TResource extends string = string,
  TRole extends string = string,
  TScope extends string = string,
>(
  engine: IamEngine<TAction, TResource, TRole, TScope>,
  action: TAction,
  resourceType: TResource,
  opts?: Pick<IamHono.IOptions<TScope>, 'getUserId' | 'getEnvironment' | 'onDenied' | 'onError'> & { scope?: TScope },
): (c: HonoContext, next: HonoNext) => Promise<Response | undefined>

Unlike the Express guard, the Hono guard does accept onError; it takes getUserId, getEnvironment, onDenied, onError, and a fixed scope. It has no getAction, getResource, or getScope.

// Fixed action and resource; id from c.req.param('id').
app.delete('/posts/:id', iamGuard(engine, 'delete', 'post'), (c) => c.json({ deleted: c.req.param('id') }))

// Fixed scope for an admin surface.
app.post('/admin/users', iamGuard(engine, 'manage', 'user', { scope: 'admin' }), createUser)

// Hide existence instead of admitting a denial.
app.get(
  '/drafts/:id',
  iamGuard(engine, 'read', 'draft', {
    // The package never answers 404; this is your own handler choosing to.
    onDenied: (c) => c.json({ error: 'Not found' }, 404),
  }),
  readDraft,
)

Subject, scope, and environment

FieldWhere it comes fromOverride
subject idc.get('userId')getUserId (both gates)
resource typefirst segment of c.req.path (middleware) or the resourceType argument (guard)getResource / the argument
resource idsecond path segment (middleware) or c.req.param('id') (guard)getResource / check in the handler
actioniamActionForMethod(c.req.method) (middleware) or the action argument (guard)getAction / the argument
scopegetScope(c) (middleware) or opts.scope (guard)either
environmentthe Hono defaultEnvgetEnvironment

Forwarded-IP normalisation

Hono is the only adapter with a built-in IP opt-in, trustCloudflareHeaders, and it is off by default.

function defaultEnv(c: HonoContext, trustCloudflareHeaders = false): IamRequest.IEnvironment {
  return iamExtractEnvironment(
    {
      ip: trustCloudflareHeaders ? c.req.header('cf-connecting-ip') : undefined,
      headers: {
        'x-forwarded-for': c.req.header('x-forwarded-for'),
        'x-real-ip': c.req.header('x-real-ip'),
        'user-agent': c.req.header('user-agent'),
      },
      method: c.req.method,
      url: c.req.url,
    },
    { trustProxy: trustCloudflareHeaders },
  )
}

Off, environment.ip is undefined. The forwarded headers are still handed to iamExtractEnvironment, but it declines to read them: with nothing in front of the app they are headers the client sets itself, and reading them unconditionally let a plain X-Forwarded-For: 10.0.0.1 satisfy an IP-conditioned admin grant here.

On, the chain is cf-connecting-ip, then x-forwarded-for, then x-real-ip, and every one of the three goes through the same normaliser: reject the whole header above 4096 characters, take the text before the first comma, trim, reject if blank or above 256. cf-connecting-ip gets no special exemption.

A condition on environment.ip with the flag off reads as a non-match, not an error: a deny rule keyed on it never fires, and the request is allowed, silently.

// Two trusted proxies: take the second entry from the right, and keep the
// rest of the extractor's output rather than rebuilding IEnvironment by hand.
iamAccessMiddleware(engine, {
  getEnvironment: (c) => {
    const hops = (c.req.header('x-forwarded-for') ?? '').split(',').map((h) => h.trim())
    return { ...defaultEnvFor(c), ip: hops[hops.length - 2] }
  },
})

Malformed JSON bodies

Hono and Next are the two adapters that parse the admin request body themselves, inside the audited handler, so iamReadJsonBody answers a 400 { error: 'Invalid request', issues: ['MALFORMED_JSON'] } for a truncated upload or a form post carrying Content-Type: application/json. Before that, the SyntaxError escaped into the generic catch and became a 500 — telling a client to retry a request that can never succeed. Express and Nest never reach this path, because their hosts parse the body first.

The parser's own message is deliberately not repeated: it quotes the offending bytes, which is caller-controlled content on its way into an operator's log.

Error mapping

SituationWhere it is caughtResponse
getUserId returns a non-string, '', or a blank stringiamIsSubjectId, inside the try401 { error: 'Unauthorized' }, not overridable
engine.can() returns falseafter the callonDenied, default 403 { error: 'Forbidden' }
any extractor throws, getUserId includedtry around the checkonError, default 500 { error: 'Internal server error' }
engine.can() rejectssame tryonError
the downstream chain (await next()) throwsnot caught hereyour app.onError

A blank subject id is refused here, not by the engine: engine.can guards length === 0, not trim(), so ' ' is a perfectly good cache key to it and any assignment stored under that key would grant its permissions. iamIsSubjectId closes that and answers 401.

engine.can() does not throw on an authorisation failure: it catches adapter and policy errors, routes them to the engine's onError hook, and returns false. See engine methods.

Admin router

iamBindAdminRouter wires the six admin endpoints onto a router you construct, and returns the same router for chaining.

export function iamBindAdminRouter<
  TAction extends string = string,
  TResource extends string = string,
  TRole extends string = string,
  TScope extends string = string,
>(
  router: IamHono.IRouterLike,
  engine: IamEngine<TAction, TResource, TRole, TScope>,
  opts: IamHono.IAdminOptions,
): IamHono.IRouterLike

IamHono.IRouterLike is the minimal .get / .put / .post / .delete surface, so any Hono-shaped router is accepted.

import { Hono } from 'hono'

const admin = new Hono()

iamBindAdminRouter(admin, engine, {
  authorize: (c) => c.req.header('x-admin-token') === ADMIN_TOKEN,
  csrfCheck: false, // bearer-token API, no browser involved
  onAdminMutation: (event) => auditLog.write(event),
})

app.use('/api/access-admin/*', rateLimiter({ windowMs: 60_000, limit: 30 }))
app.route('/api/access-admin', admin)
EndpointEngine callAudit action / targettargetId
GET /policiesadmin.listPolicies()none (reads never audit)
GET /rolesadmin.listRoles()none
PUT /policiesadmin.savePolicy(await c.req.json())replace / policyundefined
PUT /rolesadmin.saveRole(await c.req.json())replace / roleundefined
POST /subjects/:id/rolesadmin.assignRole(id, roleId, scope)create / role-assignmentc.req.param('id')
DELETE /subjects/:id/roles/:roleIdadmin.revokeRole(id, roleId)delete / role-assignmentc.req.param('id')

Mutations write c.req.method and c.req.path into the audit event, and targetId is the document's id for policy and role writes — the same on all four adapters.

Body validation on role assignment

POST /subjects/:id/roles is the only admin endpoint in any wrapper that validates its body before touching the engine:

ConditionResponse
body is not a non-null, non-array object400 { error: 'invalid body' }
roleId is not a string, is empty, or exceeds 128 characters400 { error: 'invalid roleId' }
scope is present and is not a string of 1–128 characters400 { error: 'invalid scope' }

These are returned responses, not throws, so iamWithAdminAudit records them with success: true. A hook counting failed assignments must inspect the response, not event.success.

Options

OptionTypeDefaultMeaning
authorize(c) => unknown | Promise<unknown>requiredRuns on every endpoint, read and write. A falsy return (false, 0, '', null, undefined, NaN) is 401; a throw is 500. Return the actor itself, not true true authorizes and records actor: undefined with a one-time console.warn.
csrfCheck((c) => boolean) | falseiamDefaultCsrfCheckRuns on every admin request, reads included. false disables the phase.
onUnauthorized(c) => Responsec.json({ error: 'Unauthorized' }, 401)Replaces the falsy-authorize response.
onError(err, c) => Responsec.json({ error: 'Internal server error' }, 500)Wraps a throwing authorize or handler.
onAdminMutationIamAdminAudit.HooknoneFire-and-forget, fires on success and failure, never on GET.
redactPath(path: string) => stringidentityRewrites event.path before the hook.
onAuditHookError(err, event) => voidconsole.errorSink for a throwing hook.
includeErrorMessagebooleanfalseevent.error becomes err.message instead of the class name.

The last four are the shared IamAdminAudit.IOptions; their exact semantics, the event shape, and the CSRF predicate are documented once on the generic helpers page.

Edge runtimes

The module touches no Node built-in — no process, no Buffer, no crypto import — so it runs unchanged on Workers, Deno Deploy, Bun, and Vercel Edge. What decides edge compatibility is the adapter, not the wrapper:

RuntimeWorks with
Cloudflare Workersmemory, HTTP, or the Redis adapter over an HTTP-based Redis client
Deno Deploymemory, HTTP, Redis over a Deno-compatible client
Bunanything, including the Node drivers behind the drizzle and prisma adapters
Vercel Edgememory, HTTP, or a serverless driver bundled for the edge build

An isolate is short-lived and may be recycled between requests, so the engine's in-process caches warm per isolate. Pair a shared backend with the Redis invalidator so a policy change reaches every isolate.

API reference

ExportKindPurpose
iamAccessMiddleware(engine, opts?)functionBlanket middleware with inferred action and resource
iamGuard(engine, action, resourceType, opts?)functionPer-route middleware with a fixed action and resource
iamBindAdminRouter(router, engine, opts)functionWires the six admin endpoints onto a router and returns it
IamHono.IOptions<TScope>interfaceOptions for both gates
IamHono.IAdminAuthorizetype(c) => boolean | Promise<boolean>
IamHono.IAdminOptionsinterfaceauthorize plus onUnauthorized, onError, and IamAdminAudit.IOptions
IamHono.IRouterLikeinterfaceMinimal .get / .put / .post / .delete router surface
import type { IamHono } from '@gentleduck/iam/server/hono'

const opts: IamHono.IOptions = {
  getUserId: (c) => (c.get('userId') as string | undefined) ?? null,
}

const adminAuth: IamHono.IAdminAuthorize = (c) => c.req.header('x-admin-token') === ADMIN_TOKEN

The namespace is type-only and costs nothing at runtime.

Gotchas

  • await next() sits outside the wrapper's try. A route-handler exception reaches your app.onError, not this middleware's.
  • Nothing in this module ever answers 404. Every refusal it produces is 400, 401, 403 or 500.
  • c.get('userId') is typed unknown; the default extractor narrows it to string | undefined. A non-string value in that slot is treated as absent, giving a 401.
  • trustCloudflareHeaders is off by default, so environment.ip is undefined until you turn it on. Only turn it on when Cloudflare is genuinely the sole ingress.
  • The guard reads c.req.param('id') only. For :postId, use the middleware with a custom getResource or check inside the handler.
  • Both gates pass attributes: {}. Ownership and status conditions belong in the handler with the loaded record.
  • iamBindAdminRouter mutates the router you pass; the return value is the same object, offered only for chaining.

See also