Skip to main content

Cookie hardening across tiers

Orkestra's ADR-0003 host split serves two audiences from one Go binary, dispatched by Host header:

  • Operator surface — Tier-1 admin console at console.example.com (JWT aud=operator)
  • Client surface — Tier-2 external clients at api.example.com (JWT aud=client)

Both surfaces issue session refresh cookies. The cookies for each surface must be scoped to a domain that the other surface cannot read. PR-D D-9 of the same ADR introduced the two env vars that control this:

  • OPERATOR_COOKIE_DOMAIN
  • CLIENT_COOKIE_DOMAIN

This page explains what goes wrong if you point either of them at a domain the other tier's host also matches, and what the right values look like for the common deployment shapes.

The threat model​

Without the per-audience split, a session cookie minted for one tier is sent by the browser to the other tier's hostname (if they share a parent domain). A few attack shapes this enables:

  1. Stolen operator cookie used to query the client API. An exfiltrated console.example.com refresh cookie hits api.example.com — both share Domain=.example.com if you set OPERATOR_COOKIE_DOMAIN=.example.com and CLIENT_COOKIE_DOMAIN=.example.com. The RequireAudience middleware rejects the JWT because aud=operator doesn't match the client surface — but the cookie is exposed, the attacker can probe other audiences with it.
  2. Subdomain XSS escalates across tiers. A Tier-2 client's app at client.example.com runs untrusted JS (per design — clients build their own UIs). If client.example.com shares a parent domain with the operator cookie, that JS reads the operator's refresh token.
  3. Browser bug + permissive Domain= leaks across origins. Browsers have had cookie-jar bugs before. Tightening the Domain= attribute is defence in depth — the smaller the blast radius, the less a bug costs.

The audience JWT gate (RequireAudience middleware, see shared/middleware/audience.go) catches misuse at the API layer. The cookie-domain split catches it one step earlier, at the browser.

What to set​

Dev (single host, browser-relaxed cookies)​

# docker/.env — what `make init` produces.
OPERATOR_COOKIE_DOMAIN=
CLIENT_COOKIE_DOMAIN=
COOKIE_SECURE=false
COOKIE_SAME_SITE=lax
ALLOW_LOCALHOST_REDIRECTS=true

Both domains are empty on purpose, and that is the whole dev configuration. An empty value writes no Domain attribute at all, so each refresh cookie is host-only — scoped to exactly the host that minted it, which round-trips on localhost, on *.localhost and on a LAN IP for multi-device testing (an IP literal cannot be a cookie Domain). This is the shipped default in every environment (defaultOperatorCookieDomain / defaultClientCookieDomain in config.go, both returning ""), not a dev-only relaxation.

note

COOKIE_DOMAIN is not read by the backend — OPERATOR_COOKIE_DOMAIN and CLIENT_COOKIE_DOMAIN are the only inputs (config.go:335-336). A COOKIE_DOMAIN= line carried over from an older deployment is inert; delete it rather than tuning it.

COOKIE_SAME_SITE is read into config and never consumed — every cookie the backend mints writes SameSite=Lax literally, in every environment — so this key changes nothing and cannot be used to relax anything.

Add an /etc/hosts entry (127.0.0.1 client.localhost) if your resolver does not answer *.localhost itself; the operator console needs none, because it is localhost end to end. Note that localhost is not in the Public Suffix List, so sibling *.localhost labels are not one site: client.localhost and api.localhost are different sites to a browser, and a SameSite=Lax cookie will not cross between them. That is why the dev client API answers on client.localhost:3000, the same hostname the client SPA is served on — see Refresh + cookie scope. Do not ship this configuration to production.

Production — single domain, separate subdomains​

The most common shape. Operator on console.example.com, clients on api.example.com.

OPERATOR_COOKIE_DOMAIN=console.example.com
CLIENT_COOKIE_DOMAIN=api.example.com
COOKIE_SECURE=true
ALLOW_LOCALHOST_REDIRECTS=false

Each cookie is scoped to a single hostname (no leading dot, no parent-domain match). The browser will NOT send the operator cookie to api.example.com and vice versa.

Production — different domains entirely (preferred for highest isolation)​

If your Tier-2 clients run on a different domain entirely (e.g. orkestra-app.io), use that.

OPERATOR_COOKIE_DOMAIN=console.example.com
CLIENT_COOKIE_DOMAIN=api.orkestra-app.io
COOKIE_SECURE=true

Completely separate cookie jars in the browser. No cross-tier leakage is possible even if SameSite gets bypassed.

What NOT to do​

# ❌ shared parent domain — both cookies sent to both hosts.
OPERATOR_COOKIE_DOMAIN=.example.com
CLIENT_COOKIE_DOMAIN=.example.com

A Domain attribute matches the named domain and every host under it, so either value above puts that tier's refresh cookie on console.example.com and api.example.com — defeating the host split. Setting only one of them to .example.com is half the same defect. Leaving both empty is not the danger: an empty value writes no Domain at all and the cookie stays host-only. The danger is a value that spans both audiences.

The validator in backend/internal/shared/config/config.go doesn't currently reject this combination. Treat it as a deployment-time review item until a hard gate lands (tracked in the ADR-0003 follow-ups).

Why a client-tier social login relays to the client API host​

Every OAuth provider is registered with one redirect URI per provider, and that URI lives on the operator host. A client-tier flow therefore returns from the identity provider to console.example.com — a host that, by the model above, cannot set a cookie for api.example.com (the browser rejects a Domain that does not match the response host, and a shared parent domain is exactly what we refuse to configure). The callback does not try: for a client-tier flow it completes the identity-provider half, stores a 60-second single-use record, and redirects the browser to CLIENT_API_URL/v1/auth/client/oauth/complete?relay=…. That endpoint, on the client API host, takes the record once, requires the orkestra_oauth_state cookie the same host set when the flow started (so a login-CSRF attempt that finished someone else's flow is refused before any token exists), clears it, and then either sets the client refresh cookie on its own host and sends the user to the client SPA or — when the flow had already failed at the identity provider — sends them there with the failure code. Nothing about a client-tier login is decided for the browser anywhere else. Set CLIENT_API_URL to the client API's public origin; it is derived from CLIENT_API_HOST when empty.

Verifying​

After deploying, inspect the cookies in a browser dev-tools session. Look at the Domain= attribute on the refresh cookie:

# Operator console:
console.example.com → set-cookie: orkestra_cookie=...; Domain=console.example.com; Path=/; Secure; HttpOnly; SameSite=Lax

# Client API:
api.example.com → set-cookie: orkestra_cookie=...; Domain=api.example.com; Path=/; Secure; HttpOnly; SameSite=Lax

SameSite=Lax is the expected value on both — the backend writes it literally and COOKIE_SAME_SITE cannot change it. If you see Domain=.example.com on either, that tier's variable holds the parent domain instead of the single hostname. Confirm with:

docker exec orkestra-backend env | grep COOKIE_DOMAIN
# OPERATOR_COOKIE_DOMAIN=console.example.com
# CLIENT_COOKIE_DOMAIN=api.example.com

And confirm the audience gate is also working:

TOKEN=$(./scripts/devtoken.sh administrator --quiet) # aud=operator
curl -fsS -H "Authorization: Bearer $TOKEN" https://api.example.com/v1/admin/modules
# Expected: 401 {"code":"audience_mismatch", ...}

See also​