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),
}),
)
accessMiddleware and guard no longer fall back to the x-user-id request
header. Default identity is c.get('userId') only - wire it from upstream
auth (JWT middleware, cookie session). If you intentionally proxy a verified
identity header from a trusted ingress, pass getUserId explicitly.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.
guard() helper assumes the route param is named id.
For nested resources or different param names, prefer accessMiddleware() with a custom
getResource function or a manual engine.can() call in the handler.Environment extraction
Hono's default environment extractor reads:
cf-connecting-ip(Cloudflare) orx-forwarded-forfor the IP addressuser-agentfor the user agent stringDate.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
IamRedisAdapteragainst 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
| Option | Type | Default | Description |
|---|---|---|---|
getUserId | (c) -> string or null | c.get('userId') only (SEC-101) | Extract the subject ID |
getAction | (c) -> string | HTTP method map | Map the request to an action |
getResource | (c) -> Resource | Infer from URL path | Map the request to a resource |
getEnvironment | (c) -> Environment | CF/forwarded IP + user agent + timestamp | Extract environment context |
getScope | (c) -> string or undefined | undefined | Extract scope from the request |
onDenied | (c) -> Response | 403 JSON | Custom denial response |
onError | (err, c) -> Response | 500 JSON | Custom 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 foraccessMiddlewareandguard.IamHono.IAdminAuthorize- signature of the required adminauthorizecallback.IamHono.IAdminOptions- options forbindAdminRouter(authorize,onUnauthorized,onError).IamHono.IRouterLike- minimal router shapebindAdminRouteraccepts.
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.