Skip to content

Authentication and sessions

HOG is a Backend-for-Frontend (BFF): it is the OpenID Connect relying party, not an identity provider. A frontend developer configures an issuer, a client ID and secret, and a redirect URL, and HOG runs the entire login protocol on the browser's behalf — the single-page app never sees a token.

The browser flow

Login is the OIDC Authorization Code flow with PKCE enabled by default (it can be turned off for confidential clients that don't need it). HOG issues state and nonce, carries them across the redirect in a short-lived, encrypted hog_login cookie, and validates both on return — this keeps the login round trip stateless and cluster-safe: any replica can complete a callback a different replica started.

Once the IdP redirects back with a code, HOG exchanges it, verifies the ID token against the IdP's JWKS, and — only when the configured identity model needs claims the ID token doesn't carry — fetches userinfo. The result becomes a session, sealed into an encrypted, HttpOnly cookie.

Why SameSite=Lax, not Strict

The session cookie uses SameSite=Lax, not Strict. The browser returning from the IdP's login page to HOG's /auth/callback is a top-level, cross-site navigation, and a Strict cookie is withheld on exactly that request — it would break the redirect back into the app on every login. Lax still sends the cookie on that safe, top-level GET, while withholding it on cross-site subrequests and form submissions, enough to blunt CSRF for state-changing requests. HOG layers Fetch-metadata based origin checks on top rather than relying on the cookie attribute alone.

The session fingerprint

At login, HOG derives a fingerprint from a configurable set of request attributes — by default just User-Agent — as base64(sha256(canonical(headers))), and seals it inside the session cookie. Every read recomputes the fingerprint and compares it in constant time; a mismatch invalidates the session. This is a deliberate trade-off, not true proof-of-possession: the attributes are spoofable, and a UA string can drift across a browser auto-update and force a benign re-login. A client-signed proof-of-possession scheme was considered and rejected because it would require the frontend to write auth code — violating the "zero frontend auth code" goal the BFF model exists to deliver. The fingerprint header set is configurable so operators can tune the trade-off.

Two views of identity

HOG keeps two identity shapes. PublicView is what the SPA sees from the session-info endpoint — subject, passport claims, groups, expiry — never tokens or the fingerprint. Principal is the server-side view on the request context, read by the authorization gate, the reverse proxy, and plugins; it carries the access token but never the refresh token or fingerprint, which stay inside the session-lifecycle code alone. Nothing downstream of the session gate touches raw session bytes.

The principal's subject defaults to the token's own sub, but identity.subjectClaim can point it at any claim instead, on both the cookie and the Bearer paths. This is what makes a token that carries no sub at all — as some providers issue for access tokens — usable as an identity source: the subject just comes from wherever that provider does put it.

API Bearer auth: the non-browser path

Routes typed service also accept Authorization: Bearer <jwt> — a token a non-browser client obtained directly from the IdP, typically via client credentials. HOG verifies it offline against the IdP's cached JWKS (signature, issuer, expiry, and audience — configurable, defaulting to the client ID) and projects it into the same Principal shape the cookie flow produces, so downstream code can't tell which path resolved the request.

Not every provider signs access tokens the same way it signs ID tokens. That mismatch is what the bearer block exists for: with certain providers, access tokens verify against a key set of their own (bearer.jwksURL, or the jwks_access_token_uri a discovery document can advertise separately from jwks_uri), and they carry no sub and no standard aud at all — the client identity lives in a claim like client_id instead, checked via bearer.audienceClaim. HOG treats an access token without sub as valid rather than rejecting it outright, provided identity.subjectClaim names a claim the token does carry.

Resolution is cookie-first — a valid session takes precedence — and Bearer is accepted only on service routes, never on app (SPA) routes, since browsers never send Authorization automatically and so present no CSRF surface there.

The claims that make up a user's passport and how groups are derived are configured once, in a shared identity model used by both paths. That separation is what lets HOG run as a pure, cookieless API gateway — Bearer only, no session cookie, no login endpoints — using the same identity-projection rules a full BFF deployment uses.

An instance in that shape still needs an IdP, because verification is a protocol operation: discovery resolves the key set and every token is checked against the issuer. But it needs only the parts verification uses, and verificationOnly: true narrows the resource to exactly those — the issuer and the client id, which is the expected audience. The credential of the authorization-code flow is then not merely unused but refused, so a deployment that runs one instance for login and another for verification keeps the client secret in the one that performs the exchange. Declaring it is deliberate rather than inferred from the missing session block: adding a session later must change what a configuration does, not what its existing IdP resource already meant.

Pluggable session state

The session cookie's contents depend on which state provider is configured:

  • Cookie-self-contained (default). The full session — passport, groups, access token, expiry, fingerprint — is sealed with AES-256-GCM directly in the cookie, auto-chunked across numbered cookies if it grows too large for one. No server-side state: any replica decrypts any session with the shared key, keeping the cluster coordination-free. The trade-off is that a refresh token is too sensitive to ever hold client-side, so it's discarded at login — this mode has no silent refresh; an expired session requires re-login.
  • Delegated (opt-in). The cookie holds only an opaque session ID; the full record — including the refresh token — lives in an external store keyed by that ID, encrypted by HOG before it ever reaches the store. This unlocks silent refresh: HOG quietly renews the access token as it nears expiry. The core module defines a minimal key-value-with-TTL interface (session.StateStore) and stays dependency-free; a store is a plugin living in its own module. HOG ships one, statestore-valkey (Valkey-backed), as the reference implementation and a ready-to-use default — a developer can plug in another store the same way.

Both modes share the same Manager interface, so nothing above the session layer needs to know which one is active.

Identity projection to backends

Before a request reaches a backend, HOG strips every inbound X-User-* header — unconditionally, even unauthenticated — so a client can never smuggle identity past the gateway. If a Principal was resolved, HOG then injects its own: X-User-Id always, one header per persisted passport claim, and a groups header (default X-User-Groups) if groups are configured. This strip-then-inject order is what makes the header set trustworthy for backends to consume directly.

Backends never receive HOG's own session or login cookies by default (the Cookie header is stripped before proxying, with an explicit opt-in to pass it through) or the access token. The token is injected as Authorization: Bearer <token> only when a route explicitly enables it — off by default, never logged — because forwarding it hands a backend the ability to call further upstream services as the user, a capability that should be a deliberate choice, not a default.

The identity assertion

X-User-* headers work for one hop: HOG projects a trustworthy identity onto the request it forwards, but that identity doesn't survive a second HOG instance sitting further down the chain, because a plain header is exactly what the previous section says never to trust from an inbound request. The identity assertion exists to cross that second hop without falling back to a plain, forgeable header, and without asking the downstream instance to re-run userinfo against the IdP for an identity the upstream instance already resolved.

Rather than trust a header, the upstream instance signs one: a compact, Ed25519-signed JWS naming the subject, passport, and groups it resolved, minted fresh per request with a short lifetime (60 seconds by default) and carried in its own header, never the caller's session or access token. The downstream instance verifies the signature and issuer against a configured key before trusting anything in it, and, by default, only lets a valid assertion enrich a principal a Bearer token has already authenticated on that same request — it does not treat the header as a credential on its own unless an operator explicitly opts into that.

That default is the load-bearing decision: an assertion is deliberately weaker proof than the things HOG already verifies cryptographically end to end (an ID token against the IdP's JWKS, a session cookie against HOG's own seal). It carries no audience and no unique id, so any instance configured with the matching issuer and key accepts it, and a captured token replays for the rest of its lifetime plus clock-skew tolerance. Accepting one is inherently trusting the issuing instance's own resolution of the principal, not independently re-deriving it — a relationship between two deployments you operate and have chosen to connect this way, not an independent authentication check. Binding it to an already-authenticated principal by default keeps that trust relationship from becoming, by itself, a way to authenticate a request that reached the downstream instance directly.

See operations: authentication for configuration details.