Skip to main content

hono

Hono middleware and per-route guard for duck-iam. Edge-friendly with cf-connecting-ip detection and zero hard dependencies.

Install

import { accessMiddleware, guard } from '@gentleduck/iam/server/hono'

Edge-friendly. Works on Bun, Cloudflare Workers, Deno Deploy, Vercel Edge - anywhere Hono runs.


Global middleware

Apply access control to all routes under a path pattern. If no user ID is found, the middleware returns 401 immediately.

import { accessMiddleware } from '@gentleduck/iam/server/hono'

app.use(
  '/api/*',
  accessMiddleware(engine, {
    // SEC-101: the default reads `c.get('userId')` only - populate it from
    // upstream auth (JWT, cookie session). The x-user-id header is no longer
    // a fallback because any unauth client can spoof it.
    getUserId: (c) => c.get('userId') as string | null,
    getScope: (c) => c.req.header('x-org-id'),
    onDenied: (c) => c.json({ error: 'Forbidden' }, 403),
    onError: (err, c) => c.json({ error: 'Internal server error' }, 500),
  }),
)

Per-route guard

Guard individual Hono routes with fixed action and resource types.

import { guard } from '@gentleduck/iam/server/hono'

app.delete('/posts/:id', guard(engine, 'delete', 'post'), async (c) => {
  // Only reached if the user can delete posts
  return c.json({ deleted: true })
})

app.post('/admin/users', guard(engine, 'manage', 'user', { scope: 'admin' }), async (c) => {
  return c.json({ created: true })
})

The guard automatically reads c.req.param('id') as the resource ID when available.


Environment extraction

Hono's default environment extractor reads:

  • cf-connecting-ip (Cloudflare) or x-forwarded-for for the IP address
  • user-agent for the user agent string
  • Date.now() for the timestamp

Override with getEnvironment to add custom fields:

accessMiddleware(engine, {
  getEnvironment: (c) => ({
    ip: c.req.header('cf-connecting-ip') ?? c.req.header('x-real-ip'),
    userAgent: c.req.header('user-agent'),
    region: c.req.header('cf-ipcountry'),
    timestamp: Date.now(),
  }),
})

Edge runtime notes

The Hono integration imports zero Node-specific APIs. It works in:

  • Cloudflare Workers - pair with IamRedisAdapter against Upstash Redis or a KV adapter
  • Deno Deploy - works with deno-friendly adapters
  • Bun - full Node compatibility plus Hono's edge optimizations
  • Vercel Edge Functions - bundle the adapter as part of the Edge build

For database-backed adapters at the edge, ensure your driver supports the runtime (Neon serverless for Postgres, planetscale for MySQL, etc.).


Options reference

OptionTypeDefaultDescription
getUserId(c) -> string or nullc.get('userId') only (SEC-101)Extract the subject ID
getAction(c) -> stringHTTP method mapMap the request to an action
getResource(c) -> ResourceInfer from URL pathMap the request to a resource
getEnvironment(c) -> EnvironmentCF/forwarded IP + user agent + timestampExtract environment context
getScope(c) -> string or undefinedundefinedExtract scope from the request
onDenied(c) -> Response403 JSONCustom denial response
onError(err, c) -> Response500 JSONCustom error handler

Admin router

Bind admin CRUD endpoints onto a Hono router. The authorize callback is required - the factory throws if it's missing.

import { Hono } from 'hono'
import { bindAdminRouter } from '@gentleduck/iam/server/hono'

const admin = new Hono()
bindAdminRouter(admin, engine, {
  authorize: (c) => c.req.header('x-admin-token') === process.env.ADMIN_TOKEN,
})

app.route('/api/access-admin', admin)

Exposes the same six endpoints as the Express router (GET / PUT /policies + /roles, POST/DELETE /subjects/:id/roles[/:roleId]). Options conform to IamHono.IAdminOptions with authorize (required) of shape IamHono.IAdminAuthorize, plus onUnauthorized and onError. bindAdminRouter is generic over IamHono.IRouterLike so it accepts any router implementing the minimal .get/.put/.post/.delete surface.

CSRF protection (default-on)

Mutation handlers (PUT/POST/DELETE) run a Sec-Fetch-Site check by default (SEC-103 / CAVEAT-2). Browsers populate the header automatically; cross-site form posts are rejected with 403, same-site / same-origin requests pass. Non-browser callers (no header) pass - they must be gated by bearer / mTLS.

// Default - cookie-auth admin UIs get CSRF protection with no opt-in.
bindAdminRouter(admin, engine, { authorize })

// Server-to-server bearer/mTLS API - disable the check entirely.
bindAdminRouter(admin, engine, { authorize, csrfCheck: false })

// Stricter - Origin allowlist.
const ADMIN_ORIGINS = new Set(['https://admin.example.com'])
bindAdminRouter(admin, engine, {
  authorize,
  csrfCheck: (c) => ADMIN_ORIGINS.has(c.req.header('origin') ?? ''),
})

Types

All types live under the Hono namespace at @gentleduck/iam/server/hono. Type-only - zero bundle cost.

  • IamHono.IOptions - options for accessMiddleware and guard.
  • IamHono.IAdminAuthorize - signature of the required admin authorize callback.
  • IamHono.IAdminOptions - options for bindAdminRouter (authorize, onUnauthorized, onError).
  • IamHono.IRouterLike - minimal router shape bindAdminRouter accepts.
import type { IamHono } from '@gentleduck/iam/server/hono'

const opts: IamHono.IOptions = {
  // SEC-101: read from `c.set('userId', ...)` populated by upstream auth.
  getUserId: (c) => c.get('userId') as string | null,
}

Deprecated bare aliases (IHonoOptions, IAdminAuthorize, IAdminOptions, IRouterLike) remain for back-compat and will be removed in 3.0.