ADR 0003: Authenticating preview URLs
Date: 2026-09-11 Status: accepted
Context
ADR 0001 left an open item: "Preview hosts are currently reachable by anyone who can reach
the Ingress." Every environment publishes <env>--<app>.<domain>, and nothing checked who
was asking.
On a bank's internal network "anyone who can reach the Ingress" is every employee, every contractor's laptop and every other workload in the cluster. A developer testing a change against production-shaped data had, without being told, published that data to the whole network. No customer would pass a security review with that open, and it is the kind of finding that ends an evaluation rather than generating a ticket.
Three things had to be true of the fix:
- The API must not be in the data path. Every request to every preview would otherwise depend on the control plane being up and fast. The API is a single replica by design (see the SQLite decision), so putting it in front of developer traffic would make a restart an outage for work that has nothing to do with it.
- A cookie for one environment must not open another. Every preview of an application shares a parent domain, so a browser will send the same cookie to all of them.
- It must degrade honestly. An evaluation on a laptop should not require this, but a real install should not be able to have it silently off.
Decision
The API mints a short-lived signed cookie after a normal session check. The router verifies the signature itself on every preview request.
- Format (
api/pkg/preview):<env>.<expiryUnix>.<HMAC-SHA256>. The environment name and the expiry are inside the signed material, so a cookie cannot be replayed against a different environment and its lifetime cannot be extended by editing it. Comparison is constant time. - Shared package, not a copy. Both sides import
api/pkg/preview. Two implementations of one wire format would eventually drift, and the first symptom would be every preview in the customer's cluster returning 401. - Key distribution. The chart generates one key into a Secret and keeps it across
upgrades (regenerating on every
helm upgradewould sign everyone out of every preview). The API copies it into each application namespace, not the operator: the operator deliberately holds no Secret write permission outside its own namespace (ADR 0006), and re-granting it for this one key would undo that. - Scope. Read access to the environment is the bar, so a reviewer an environment was shared with can open its preview. Eight hours, so a cookie copied from a shared machine is not a lasting grant.
- Response shape. A browser (
Accept: text/html) is redirected to sign in and returned to what it asked for. Anything else gets 401 withWWW-Authenticate, because a service call has no use for an HTML login page and it only pollutes its logs. - Ordering in the router. The check runs after the host resolves to an environment, so the cookie can be environment-scoped, and before waking a sleeping environment, so an unauthenticated request cannot spin up a developer's copies.
Consequences
- Preview URLs are closed by default in the chart (
previewAuth.enabled: true). - With no key configured the check is off and the router says so loudly at startup. That is the evaluation path; the install guide states plainly what it means.
- A person opening a preview for the first time in a session is bounced through the dashboard. That is one redirect, and it is what makes the URL safe to share internally.
- Service-to-service calls between environments are unaffected: they travel east-west through the router's per-service listeners, not the edge.
- The cookie is not a session. Revoking a Nazeel session does not revoke an already-issued preview cookie before it expires. Eight hours bounds that, and shortening it is a values change. Making it a session lookup would put the API back in the data path, which is the thing this decision exists to avoid.