Request lifecycle¶
Every request that reaches HOG passes through the same shape: two gateway-wide
edge layers wrapping the whole handler, an outer tracing wrapper, a route
match, a fixed chain of built-in per-route stages, two guarded slots for
developer plugins, and finally a terminal handler. The per-route chain is
assembled once per route at boot (app.Build, chain.Skeleton) and applied
to every request that route serves; the two edge layers are assembled once
for the whole gateway and wrap everything — routes and the raw /auth/*
endpoints alike.
The gateway-wide edge layers¶
app.Build wraps the entire handler — the ServeMux plus its OpenTelemetry
wrapper — in two outermost layers, in this order (outermost first):
forwarded (chain.Forwarded) strips inbound X-Forwarded-For,
X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port, X-Real-Ip, and
Forwarded headers whenever the immediate peer isn't in Gateway.spec.trustedProxies.
It runs before everything else, including the OpenTelemetry wrapper, because
otelhttp reads raw X-Forwarded-For for the span's client.address —
untrusted values must be gone before anything, including tracing, ever sees
them. See deployment: behind a trusted proxy.
security (security.Build) applies CSRF protection
(net/http.CrossOriginProtection) and static security response headers
(X-Frame-Options, X-Content-Type-Options, Referrer-Policy,
Strict-Transport-Security, and an opt-in Content-Security-Policy) to
every response. It wraps the OpenTelemetry layer (runs just inside
forwarded, just outside otel) and is gateway-wide — it covers
/auth/login, /auth/callback, and every other raw endpoint, not just
routed traffic. See operations: security.
Both layers apply to everything — they are not part of the per-route
skeleton below, precisely so a route can't opt out of them and the raw auth
endpoints (which have no Route resource of their own) are still covered.
The fixed per-route skeleton¶
Once a request reaches the ServeMux and matches a Route, it runs through
that route's own composed chain. chain/builtin.go defines the built-in
per-route stages in a fixed order — outermost first:
recover catches a panic anywhere further in — including the terminal —
logs it with the request's trace and span IDs, and returns 500 instead of
crashing the process.
request-id reads an inbound X-Request-Id header or generates one, and
echoes it back on the response, so a request can be correlated across logs.
access-log wraps the response writer to capture the final status code and
emits one structured log line after the handler returns — method, path,
status, duration, and, when a Telemetry resource is configured, trace
correlation and additional fields.
session resolves the caller's identity into a request-scoped Principal.
For app routes it reads the encrypted session cookie. For service routes
it also accepts an Authorization: Bearer token, verified against the
configured IdP, when no valid cookie is present — the cookie always wins if
both are sent. When identity.assertion.accept is configured, a service
route runs one more identity sub-stage after Bearer: it verifies an inbound
identity assertion (a signed statement from a peer HOG instance) and, by
default, uses it only to enrich the principal Bearer already resolved,
never to authenticate a request by itself. A missing or invalid credential
does not reject the request here; it simply leaves it unauthenticated.
auth-gate enforces the route's effective auth: required|public setting.
This is the stage that turns a missing identity into a rejection: a browser
(app) route gets a 302 redirect to the login path with the original URL
preserved as return_to; a service route gets a 401 with a
WWW-Authenticate header.
authz evaluates the route's effective authorization set — its own
access.authorize plus every matching RouteGroup's — against the resolved
identity and the request's attributes. Any policy that denies returns 403,
or a 302 to access.onDeny.redirect when the route configured one (app
routes only — a service route always keeps the 403). This stage runs
independently of whether a session or IdP is configured at all, since a
policy can match on request attributes alone.
A deny short-circuits right there — no further stage runs, and the response body carries no policy detail (the reason is logged and recorded on the request span instead):
sequenceDiagram
autonumber
participant B as Browser
participant H as HOG
B->>H: GET /admin (session groups = [viewers])
Note over H: authz: Policy require groups=[admins] → not satisfied
H-->>B: 403 forbidden (reason logged, not leaked)
projection strips any inbound X-User-* headers as an anti-spoofing
measure and, only when a principal is present in context, injects identity
headers for the backend to trust: a subject header, a groups header, and
either derived or explicitly mapped claim headers. The identity assertion
header follows the same anti-spoofing pattern elsewhere in the chain: an
inbound copy is stripped before the session sub-stage that would verify it
ever reads it, and the only version a backend can receive is one HOG mints
itself, on a route with forwardIdentity: true.
Put together, a request that clears every gate looks like this — session
resolves the Principal, auth-gate and authz let it through, and projection
hands the backend a trustworthy identity instead of the raw cookie:
sequenceDiagram
autonumber
participant B as Browser
participant H as HOG
participant K as Backend
B->>H: GET /api/orders (session cookie)
Note over H: session → Principal · auth-gate ✓ · authz ✓ · projection sets X-User-*
H->>K: GET /orders (X-User-*, cookie stripped, optional Bearer)
K-->>H: 200 JSON
H-->>B: 200 JSON
The guarded plugin slots and the terminal¶
Two more positions exist after the fixed per-route skeleton, both reserved for developer code and both selector-matched against the route's labels, in YAML document order:
forwarded ┐
security │ gateway-wide edge layers
otel ┘ (app.Build; wrap routes AND /auth/*)
ServeMux
├─ /auth/* (login, callback, logout, session-info) → handled directly,
│ no per-route skeleton
└─ matched Route:
recover ┐
request-id │
access-log │ fixed per-route skeleton
session │ (chain.Skeleton)
auth-gate │
authz │
projection ┘
request-plugins (developer, YAML order)
response-plugins (developer, YAML order)
terminal handler
request-plugins run after every gate has passed and before the terminal — they can inspect or short-circuit the fully-authenticated, fully-authorized request.
response-plugins sit closest to the terminal, so on the way back out they are the first to see the response — they shape the final status and content (for example, reshaping an aggregated API response) before it unwinds back through projection, authz, session, access-log, and out.
The terminal handler ends the per-route chain: static, reverse-proxy,
api (aggregation), or the built-in health system endpoint. The
/auth/login, /auth/callback, /auth/logout, and session-info endpoints
aren't Route resources at all — they're mounted directly on the ServeMux
and reached only through the gateway-wide edge layers above, never through
the per-route skeleton (no recover/request-id/access-log/session/
auth-gate/authz/projection around them).
A full OIDC browser login exercises exactly those raw endpoints — a
protected app route's auth-gate redirects to /auth/login, which is
itself outside the per-route skeleton, before the round trip lands back on a
now-authenticated request to the original route:
sequenceDiagram
autonumber
participant B as Browser
participant H as HOG
participant I as OIDC IdP
B->>H: GET /app (no session)
H-->>B: 302 → /auth/login
B->>H: GET /auth/login
H-->>B: 302 → IdP authorize (PKCE + state)
B->>I: authenticate
I-->>B: 302 → /auth/callback?code&state
B->>H: GET /auth/callback
H->>I: exchange code, verify id_token
H-->>B: Set-Cookie (encrypted session); 302 → /app
B->>H: GET /app (session cookie)
H-->>B: 200
Reserved slots activate only when configured¶
session, auth-gate, and projection come from chain.Gates, supplied by
app.Build. When neither a session block nor an IdP resource is
configured, app.Build leaves those fields nil and chain.Skeleton fills
them with a pass-through — an all-public gateway with no IdP costs nothing
extra at these stages. authz is decided independently, per route: it
activates only when that route, directly or through a matching RouteGroup,
references at least one Policy by name.
Order consequences¶
Because auth-gate runs before authz, an unauthenticated request to a protected route is redirected or rejected before any policy is evaluated — authz never has to account for a missing identity.
Because projection runs after authz, identity headers are injected only into a request that has already been allowed through. A request denied by authz never reaches projection, and never reaches the backend.
See authentication and authorization for how to configure the session, IdP, and policy resources that fill these stages.