API gateway pattern
Centralize auth at the edge with Caddy
If you have many backend microservices, you don't want each to validate tokens independently. A gateway (Caddy in front of Olympus's case) does token validation once and forwards a clean request to backends.
Architecture
Client ──Bearer X──► Caddy gateway ──cleaned headers──► Backend microservice
│
└──introspect──► HydraCaddy:
- Receives request with Bearer token.
- Introspects (or validates JWT).
- If valid, strips
Authorizationand addsX-User-Id,X-User-Email, etc. - Forwards to backend.
Backend trusts the headers (because Caddy is its only ingress).
Caddy config
api.your-domain.com {
@auth header Authorization Bearer*
reverse_proxy {
to backend:8080
# Introspect via subrequest
transport http {
tls
}
}
# Custom directive (requires custom Caddy module)
oauth2_introspect {
introspect_url http://ciam-hydra:4445/oauth2/introspect
client_id gateway
client_secret GATEWAY_SECRET
cache_ttl 60s
inject_headers {
X-User-Id sub
X-User-Email username
X-User-Scope scope
}
reject_on_inactive
}
}A custom Caddy module like caddy-oauth2-introspect provides this directive. If not using a custom build, do introspection in a small middleware service (Hono, Express) between Caddy and your backends.
Without a custom Caddy module
api.your-domain.com {
reverse_proxy auth-gateway:3000
}Where auth-gateway is a Hono service:
const app = new Hono();
app.use("*", async (c, next) => {
const token = c.req.header("authorization")?.slice(7);
if (!token) return c.text("Unauthorized", 401);
const intro = await introspect(token);
if (!intro.active) return c.text("Unauthorized", 401);
c.req.headers.set("x-user-id", intro.sub);
c.req.headers.set("x-user-scope", intro.scope);
c.req.headers.delete("authorization"); // strip
await next();
});
app.all("/*", (c) => {
return fetch(`http://backend:8080${c.req.path}`, {
method: c.req.method,
headers: c.req.headers,
body: c.req.body,
});
});Run alongside backends in Compose.
Caching
Introspection per-request is expensive. Cache:
const cache = new Map<string, { result: any; expires: number }>();
async function introspect(token: string) {
const cached = cache.get(token);
if (cached && cached.expires > Date.now()) return cached.result;
const result = await actualIntrospect(token);
cache.set(token, { result, expires: Date.now() + 60_000 });
return result;
}60s TTL: reasonable.
Cleanup: prune expired entries periodically.
For multi-instance gateway: shared cache (Redis).
Backend trust
Backends now trust X-User-Id. CRITICAL: backends must NOT be reachable from outside Caddy. Network-level enforcement:
# docker-compose.yml
backend:
image: my-backend
# No "ports:" exposing externally
networks: [internal]If anyone can reach backend:8080 directly, they can inject X-User-Id headers and impersonate any user.
Scopes
Pass scopes too:
X-User-Scope: orders:read orders:writeBackend checks:
const scopes = req.headers["x-user-scope"].split(" ");
if (!scopes.includes("orders:read")) return res.status(403);OR enforce at gateway via Caddy match:
@orders_read header X-User-Scope *orders:read*
handle /orders/* @orders_read {
reverse_proxy orders-backend:8080
}
respond /orders/* 403Better at gateway than backend, backends become simpler.
Per-endpoint config
Some endpoints need authentication, others don't:
api.your-domain.com {
# Public health
handle /health {
respond 200
}
# Public docs
handle /docs* {
reverse_proxy docs:8080
}
# All else: auth required
handle {
oauth2_introspect ...
reverse_proxy backend:8080
}
}Rate limiting at gateway
rate_limit {
zone api-zone
events 1000
window 1m
key {http.request.header.x-user-id}
}Per-user rate limit at the gateway. Backends don't need their own.
What this pattern doesn't help
- Service-to-service auth: gateway is for external. For internal service calls, use a mesh (mTLS, SPIFFE) or shared secret.
- Webhooks from external services: gateway can't easily validate signed webhooks. Handle in backend.
Migration
If your services currently each validate tokens, migrate to gateway:
- Stand up gateway.
- Route through it for one service.
- Verify it works.
- Switch others.
- Remove per-service validation code.
Each service simplifies, single source of trust at the edge.