@authhero/proxy
v0.10.7
Published
A multi-tenant HTTP reverse proxy library for [Hono](https://hono.dev). Resolves an incoming request's `Host` header to a tenant, matches against a JSON-configured list of routes (path / method / host / headers / query), and runs each matching route throu
Readme
@authhero/proxy
A multi-tenant HTTP reverse proxy library for Hono. Resolves an incoming request's Host header to a tenant, matches against a JSON-configured list of routes (path / method / host / headers / query), and runs each matching route through an ordered chain of handlers — middleware-style transformations followed by a terminal handler that produces the response.
Designed to run on Cloudflare Workers, Node.js, Bun, or any Hono-compatible runtime. This package is data-plane only — proxy route CRUD lives in the authhero core (/api/v2/proxy-routes), and the underlying schema is shipped by each database adapter (@authhero/kysely-adapter, @authhero/drizzle, @authhero/aws-adapter).
Install
pnpm add @authhero/proxy hono @hono/zod-openapihono and @hono/zod-openapi are peer dependencies.
Concepts
A route has two parts:
match—{ hosts?, methods?, path, headers?, query? }. Path uses Hono's pattern syntax (/api/*,/users/:id).hostsaccepts exact or*.example.comwildcards.headersandqueryare name → regex.handlers— an ordered array of handler configs. Each handler is{ type, options }. The last handler is the terminal (it produces the response); earlier handlers wrap it like Hono middleware ((c, next) => …).
Built-in handlers:
| Type | Role | What it does |
| --- | --- | --- |
| cors | middleware | Replies to OPTIONS preflight; adds CORS headers to the response. |
| basic_auth | middleware | Requires a username/password in the Authorization: Basic header. |
| headers | middleware | Adds/removes request and response headers. |
| cache | middleware | Injects Cache-Control: public, max-age=N if not already set. |
| forwarded_headers | middleware | Sets X-Forwarded-For, X-Forwarded-Host, X-Forwarded-Proto, X-Real-IP, X-Original-URL from CF-Connecting-IP. Prepended automatically to any chain that omits it. |
| rewrite_cookies | middleware | Rewrites upstream Set-Cookie: …; Domain= to the request host. |
| rewrite_location | middleware | Rewrites the Location header on 3xx responses from upstream origin to request origin. |
| http | terminal | Forwards to an HTTP upstream via fetch. |
| service_binding | terminal | Forwards to a Cloudflare service binding (env.MY_API.fetch). |
| dispatch_namespace | terminal | Dispatches to a Cloudflare Workers for Platforms namespace (env.DISPATCHER.get(scriptName).fetch). script_name accepts {tenant_id}, {custom_domain_id}, {domain}, {host} placeholders. |
| redirect | terminal | Returns a 301/302/307/308 redirect. |
| static | terminal | Returns a static body (great for healthchecks). |
Client IP forwarding
forwarded_headers is guaranteed: the proxy prepends it to every route chain (and to defaultHandlers) that doesn't already declare it, so route tables stored in KV or the control plane can't silently drop the visitor IP. A chain that declares it anywhere in its handler list is left exactly as configured.
The handler ignores a CF-Connecting-IP that falls inside Cloudflare's own published ranges — that's what the header carries when the proxy is reached worker-to-worker, and stamping it would overwrite the real client IP an earlier hop already recorded in X-Forwarded-For. In that case the inbound chain is kept as-is; an inbound X-Real-IP is never trusted as a fallback because it is client-spoofable. Opt out with { type: "forwarded_headers", options: { skip_cloudflare_client_ip: false } }.
Register custom handlers via a HandlerRegistry.
Quick start (static config)
import { createProxyApp, createStaticProxyAdapter, httpRoute } from "@authhero/proxy";
const data = createStaticProxyAdapter({
hosts: {
"acme.example.com": {
tenant_id: "acme",
routes: [
httpRoute("https://acme.vercel.app"), // sugar for { match: {path:"/*"}, handlers: [{type:"http",...}] }
{
match: { path: "/healthz" },
handlers: [
{ type: "static", options: { status: 200, json: { ok: true } } },
],
},
{
match: { path: "/api/*" },
handlers: [
{ type: "cors", options: { origins: ["https://app.example.com"] } },
{ type: "http", options: { upstream_url: "https://api.acme.vercel.app" } },
],
},
],
},
},
});
const app = createProxyApp({ data });
export default app;Cloudflare service bindings
To dispatch to a service binding (e.g. an internal API Worker), declare it in your Worker entry and pass it to createProxyApp:
export default {
fetch(req: Request, env: Env, ctx: ExecutionContext) {
const app = createProxyApp({
data: ...,
bindings: { API2: env.API2 },
});
return app.fetch(req);
},
};Then a route can use:
{ match: { hosts: ["api.example.com"] }, handlers: [
{ type: "forwarded_headers", options: {} },
{ type: "service_binding", options: { binding: "API2" } },
] }Dispatching to Workers for Platforms
Route to a per-tenant worker deployed into a Cloudflare dispatch namespace. The script name supports placeholders that are substituted at request time from the resolved host:
# wrangler.toml
[[dispatch_namespaces]]
binding = "DISPATCHER"
namespace = "authhero-tenants"// One catch-all route per host dispatches to that tenant's worker.
{ match: { path: "/*" }, handlers: [
{ type: "dispatch_namespace", options: {
binding: "DISPATCHER",
script_name: "tenant-{tenant_id}-auth", // e.g. "tenant-acme-auth"
// Optional: cpu_ms, subrequests — forwarded to dispatcher.get(...)
}},
] }Available placeholders: {tenant_id}, {custom_domain_id}, {domain}, {host}. The first three come from the resolved host record; {host} is the request Host header.
Replacing the upstream-origin in cookies and redirects
The legacy Sesamy proxy rewrote Set-Cookie: Domain=upstream.example.com and 3xx Location: https://upstream.example.com/… headers so they pointed at the public custom domain. Equivalent with v2:
{
match: { path: "/*" },
handlers: [
{ type: "forwarded_headers", options: {} },
{ type: "rewrite_cookies", options: {} }, // infers upstream host from the http/service_binding handler
{ type: "rewrite_location", options: {} }, // ditto
{ type: "http", options: { upstream_url: "https://upstream.example.com" } },
],
}(You can pin upstream_host / upstream_origin explicitly if you don't want the auto-inference.)
Dynamic routes (database-backed)
For production, store routes in a database and resolve them per request. AuthHero adapter packages ship a createProxyDataAdapter(db) helper that returns a full ProxyDataAdapter (CRUD + resolveHost) wired to their schema:
import { Kysely } from "kysely";
import { createProxyApp } from "@authhero/proxy";
import { createProxyDataAdapter } from "@authhero/kysely-adapter";
const db = new Kysely({ /* dialect */ });
const app = createProxyApp({
data: createProxyDataAdapter(db),
});The proxy_routes table comes with the standard AuthHero migrations — running them via migrateToLatest(db) (or the equivalent drizzle/aws step) is enough.
Remote control plane (HTTP adapter)
If the proxy runs separately from the AuthHero control plane (different worker, different VPC, different region), use createHttpProxyAdapter to read route config over HTTP:
import {
createProxyApp,
createHttpProxyAdapter,
createCacheAdapterHostCache,
createInMemoryHostCache,
} from "@authhero/proxy";
import { createCloudflareCache } from "@authhero/cloudflare-adapter";
const httpAdapter = createHttpProxyAdapter({
baseUrl: "https://auth.example.com",
clientId: env.PROXY_CLIENT_ID,
clientSecret: env.PROXY_CLIENT_SECRET,
});
// Three-tier cache: in-memory (per-isolate) → Cloudflare Cache API (per-colo) → control plane.
const inMemory = createInMemoryHostCache(httpAdapter, { freshTtlMs: 60_000, staleTtlMs: 600_000 });
const resolver = createCacheAdapterHostCache({
upstream: inMemory,
cache: createCloudflareCache({ cacheName: "authhero-proxy-hosts" }),
freshTtlMs: 60 * 60_000, // 1 hour fresh
staleTtlMs: 23 * 60 * 60_000, // SWR for 23 more hours (24h total)
waitUntil: (p) => ctx.waitUntil(p),
});
const app = createProxyApp({
data: httpAdapter,
resolver, // share across requests
bindings: { API2: env.API2 },
});The HTTP adapter exchanges client_id + client_secret for an access token at ${baseUrl}/oauth/token, then calls GET ${baseUrl}/api/v2/proxy/control-plane/hosts/:host. The control plane must expose this endpoint with a system-scoped credential (separate from tenant tokens).
Control-plane endpoint (server side)
In your AuthHero server, opt in by wiring the resolver and pointing at the
JWKS that signs control-plane bearer tokens. Authentication is built in —
authhero verifies the bearer JWT (RS/ES algs, iss === env.ISSUER, scope
proxy:resolve_host):
import { init } from "authhero";
import { createProxyDataAdapter } from "@authhero/kysely-adapter";
const proxyData = createProxyDataAdapter(db);
const { app } = init({
dataAdapter,
proxyControlPlane: {
resolveHost: proxyData.resolveHost,
jwksUrl: `${env.ISSUER}/.well-known/jwks.json`,
// On Workers, route through a service binding to keep the request
// off the public network:
// jwksFetch: (url) => env.JWKS_SERVICE.fetch(url),
},
});KV-published read replica (recommended over the bare HTTP adapter)
The HTTP adapter reads routing over a two-hop authenticated call (token mint +
resolve) on every cache miss. To remove that hop from the hot path, have the
control plane publish each resolved host blob to a Cloudflare KV namespace,
and have the proxy read it with a single, unauthenticated KV.get. The
control-plane DB stays the source of truth; KV is a published read replica.
Proxy side — createKvProxyAdapter, with the HTTP adapter kept as the
miss/error fallback during migration:
import {
createProxyApp,
createKvProxyAdapter,
createHttpProxyAdapter,
createCacheAdapterHostCache,
createInMemoryHostCache,
type ProxyDataAdapter,
} from "@authhero/proxy";
import { createCloudflareCache } from "@authhero/cloudflare-adapter";
const kv = createKvProxyAdapter({ kv: env.PROXY_HOSTS, timeoutMs: 1000 });
const http = createHttpProxyAdapter({ baseUrl, clientId, clientSecret });
const upstream: ProxyDataAdapter = {
proxyRoutes: kv.proxyRoutes, // read-only
async resolveHost(host) {
try {
const hit = await kv.resolveHost(host);
if (hit) return hit;
} catch {
/* KV slow/unavailable — fall through */
}
return http.resolveHost(host); // miss / error fallback
},
};
const resolver = createCacheAdapterHostCache({
upstream: createInMemoryHostCache(upstream, { freshTtlMs: 60_000 }),
cache: createCloudflareCache({ cacheName: "authhero-proxy-hosts" }),
freshTtlMs: 60 * 60_000,
negativeTtlMs: 60_000,
waitUntil: (p) => ctx.waitUntil(p),
});
createProxyApp({ data: upstream, resolver });Control-plane side — wrap the adapters once with
wrapProxyAdaptersWithKvPublish (from authhero) and pass the wrapped pair to
both the management API and createApplySyncEvents, then seed existing hosts
with backfillProxyHostsToKv. The KV namespace must live in the same
Cloudflare account as the proxy Worker. See the full publish → seed → use guide
in the docs: Proxy → Shape 3b.
Host cache
resolveHost is called on every request. Wrap it with the built-in stale-while-revalidate cache:
const app = createProxyApp({
data,
cache: {
freshTtlMs: 5 * 60_000,
staleTtlMs: 60 * 60_000,
negativeTtlMs: 30_000,
waitUntil: (p) => ctx.waitUntil(p),
},
});On Cloudflare Workers, thread ExecutionContext.waitUntil through with AsyncLocalStorage so background refreshes survive the response.
Custom handlers
Define your own handler type:
import { z } from "@hono/zod-openapi";
import { HandlerRegistry, defineHandler, registerBuiltinHandlers, createProxyApp } from "@authhero/proxy";
const myHandler = defineHandler<{ secret: string }>({
type: "hmac_verify",
optionsSchema: z.object({ secret: z.string() }),
build(options) {
return async (c, next) => {
const sig = c.req.header("x-signature");
if (!sig || !await verify(sig, options.secret, c.req.raw)) {
return new Response("Bad signature", { status: 401 });
}
await next();
};
},
});
const registry = new HandlerRegistry();
registerBuiltinHandlers(registry);
registry.add(myHandler);
const app = createProxyApp({ data, registry });Route schema (zod)
All schemas are re-exported as Zod schemas (proxyRouteSchema, matchSchema, handlerConfigSchema) for validation in your own management UIs.
Exports
createProxyApp(options)— main Hono app factorycreateProxyDataPlaneRouter/createProxyDataPlaneHandler— data plane plumbingcreateStaticProxyAdapter/httpRoute— static config helperscreateHttpProxyAdapter— HTTP control-plane adaptercreateInMemoryHostCache/createCacheAdapterHostCache— cache layers (createCacheApiHostCacheis also exported but deprecated in favor of the adapter-based wrapper)HandlerRegistry/defineHandler/registerBuiltinHandlers+ each builtin handlercompileHostApp,sortRoutes,matchesHost,matchesAnyHost,buildMatchFilter
Database-backed adapters live in the AuthHero adapter packages: @authhero/kysely-adapter, @authhero/drizzle, @authhero/aws-adapter.
License
MIT
