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.
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>
| Option | Type | Default | Meaning |
|---|---|---|---|
getUserId | (c) => string | null | c.get('userId') ?? null | Subject id. Never reads a header. |
getResource | (c) => IamRequest.IResource | first two segments of c.req.path | { type: parts[0] ?? 'root', id: parts[1], attributes: {} }. |
getAction | (c) => string | iamActionForMethod(c.req.method) | Method map from the generic helpers; an unmapped method yields the reserved refusal token, not 'read'. |
trustCloudflareHeaders | boolean | false | Opt in to reading cf-connecting-ip into environment.ip. Off, ip stays undefined. |
getEnvironment | (c) => IamRequest.IEnvironment | the Hono defaultEnv below | { ip, userAgent, timestamp }. |
getScope | (c) => TScope | undefined | none — scope stays undefined | Multi-tenant scope. |
onDenied | (c) => Response | c.json({ error: 'Forbidden' }, 403) | Runs when engine.can() returns false. |
onError | (err, c) => Response | c.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,
)
x-user-id header; that fallback was removed in 2.1.0 because any unauthenticated client can send curl -H 'X-User-Id: admin'. The default is c.get('userId') only, set by upstream auth. The test does NOT default to spoofable x-user-id header pins this: a request carrying the header and no context value gets 401. If a trusted ingress injects a verified identity header, pass getUserId explicitly and validate it there.Subject, scope, and environment
| Field | Where it comes from | Override |
|---|---|---|
| subject id | c.get('userId') | getUserId (both gates) |
| resource type | first segment of c.req.path (middleware) or the resourceType argument (guard) | getResource / the argument |
| resource id | second path segment (middleware) or c.req.param('id') (guard) | getResource / check in the handler |
| action | iamActionForMethod(c.req.method) (middleware) or the action argument (guard) | getAction / the argument |
| scope | getScope(c) (middleware) or opts.scope (guard) | either |
| environment | the Hono defaultEnv | getEnvironment |
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.
getEnvironment instead of enabling this flag.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
| Situation | Where it is caught | Response |
|---|---|---|
getUserId returns a non-string, '', or a blank string | iamIsSubjectId, inside the try | 401 { error: 'Unauthorized' }, not overridable |
engine.can() returns false | after the call | onDenied, default 403 { error: 'Forbidden' } |
any extractor throws, getUserId included | try around the check | onError, default 500 { error: 'Internal server error' } |
engine.can() rejects | same try | onError |
the downstream chain (await next()) throws | not caught here | your 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)
| Endpoint | Engine call | Audit action / target | targetId |
|---|---|---|---|
GET /policies | admin.listPolicies() | none (reads never audit) | — |
GET /roles | admin.listRoles() | none | — |
PUT /policies | admin.savePolicy(await c.req.json()) | replace / policy | undefined |
PUT /roles | admin.saveRole(await c.req.json()) | replace / role | undefined |
POST /subjects/:id/roles | admin.assignRole(id, roleId, scope) | create / role-assignment | c.req.param('id') |
DELETE /subjects/:id/roles/:roleId | admin.revokeRole(id, roleId) | delete / role-assignment | c.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:
| Condition | Response |
|---|---|
| body is not a non-null, non-array object | 400 { error: 'invalid body' } |
roleId is not a string, is empty, or exceeds 128 characters | 400 { error: 'invalid roleId' } |
scope is present and is not a string of 1–128 characters | 400 { 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
| Option | Type | Default | Meaning |
|---|---|---|---|
authorize | (c) => unknown | Promise<unknown> | required | Runs 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) | false | iamDefaultCsrfCheck | Runs on every admin request, reads included. false disables the phase. |
onUnauthorized | (c) => Response | c.json({ error: 'Unauthorized' }, 401) | Replaces the falsy-authorize response. |
onError | (err, c) => Response | c.json({ error: 'Internal server error' }, 500) | Wraps a throwing authorize or handler. |
onAdminMutation | IamAdminAudit.Hook | none | Fire-and-forget, fires on success and failure, never on GET. |
redactPath | (path: string) => string | identity | Rewrites event.path before the hook. |
onAuditHookError | (err, event) => void | console.error | Sink for a throwing hook. |
includeErrorMessage | boolean | false | event.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.
iamBindAdminRouter throws at construction when opts.authorize is not a function: [@gentleduck/iam] iamBindAdminRouter requires an authorize callback. The router writes policies, roles, and assignments straight to the adapter, so there is no useful unauthenticated mode.iamDefaultCsrfCheck before authorize, rejecting requests whose Sec-Fetch-Site is cross-site or cross-origin with c.json({ error: 'Forbidden (CSRF check failed)' }, 403) and firing no audit event. That response is fixed and not covered by onUnauthorized or onError. A predicate that throws is also a 403. Requests with no such header (curl, Workers-to-Workers) pass and must be gated by bearer or mTLS auth inside authorize. A one-time console.info names the change when you did not pass csrfCheck explicitly.POST /subjects/:id/roles accepts scope in the body; the revoke route does not. It calls admin.revokeRole(subjectId, roleId) with no third argument, and the adapter contract for an omitted scope is remove every assignment for that role, across every scope. One DELETE against a subject holding that role in five tenants removes all five. To drop a single scoped assignment, add your own route calling engine.admin.revokeRole(subjectId, roleId, scope). See scoped roles.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:
| Runtime | Works with |
|---|---|
| Cloudflare Workers | memory, HTTP, or the Redis adapter over an HTTP-based Redis client |
| Deno Deploy | memory, HTTP, Redis over a Deno-compatible client |
| Bun | anything, including the Node drivers behind the drizzle and prisma adapters |
| Vercel Edge | memory, 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
| Export | Kind | Purpose |
|---|---|---|
iamAccessMiddleware(engine, opts?) | function | Blanket middleware with inferred action and resource |
iamGuard(engine, action, resourceType, opts?) | function | Per-route middleware with a fixed action and resource |
iamBindAdminRouter(router, engine, opts) | function | Wires the six admin endpoints onto a router and returns it |
IamHono.IOptions<TScope> | interface | Options for both gates |
IamHono.IAdminAuthorize | type | (c) => boolean | Promise<boolean> |
IamHono.IAdminOptions | interface | authorize plus onUnauthorized, onError, and IamAdminAudit.IOptions |
IamHono.IRouterLike | interface | Minimal .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'stry. A route-handler exception reaches yourapp.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 typedunknown; the default extractor narrows it tostring | undefined. A non-string value in that slot is treated as absent, giving a 401.trustCloudflareHeadersis off by default, soenvironment.ipisundefineduntil 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 customgetResourceor check inside the handler. - Both gates pass
attributes: {}. Ownership and status conditions belong in the handler with the loaded record. iamBindAdminRoutermutates the router you pass; the return value is the same object, offered only for chaining.
See also
- Server integrations overview for the shared pipeline and the cross-framework comparison
- Generic helpers for
iamExtractEnvironment, the CSRF predicate, and the audit pipeline - Express for the Node-only equivalent of these gates
- Redis adapter and Redis invalidator for edge-friendly storage and cache fan-out
- Admin API for what the six endpoints call
- Engine methods for
can,check, andpermissions