Why separate-origin App hosting
SupaCloud can serve hosted App frontends — plain, React, or Svelte source folders built by the Vite worker. This page explains the security boundary between those hosted frontends and the SupaCloud control plane, and why they must live on a separate registrable domain.
The session-riding risk
Section titled “The session-riding risk”The SupaCloud control plane is a cookie-authenticated API. The session cookie
is set with SameSite=Lax, host-only (no Domain attribute), HttpOnly, and
Path=/. As long as a hosted App and the API share the same origin, a
malicious (or compromised) App can call /api/* and the browser silently
attaches the visiting operator’s session cookie. The App then executes with
the full session privileges of whoever opened it.
This risk is present even with a strict default-deny CSP: script-src 'self'
and connect-src 'self' still permit same-origin fetch calls to /api, and
SameSite=Lax only withholds cookies on cross-site requests — not
same-origin or same-site ones.
Why a sibling subdomain is not sufficient
Section titled “Why a sibling subdomain is not sufficient”An obvious first answer is “put Apps on apps.supacloud.run.” It is not
sufficient. A browser site equals the registrable domain (eTLD+1), not
the full origin. app.supacloud.run and api.supacloud.run are the same
site — supacloud.run — so SameSite=Lax still forwards the session cookie
on requests from one to the other. In addition, a subdomain under the same
eTLD+1 can set a Domain=supacloud.run cookie that is visible to all sibling
subdomains (cookie-tossing / shadow-cookie attack). Neither SameSite nor
host-only cookies close these paths; the only mechanism that does under a
shared eTLD+1 is a CSP sandbox attribute that nullifies the App’s origin
entirely — a brittle approach with its own failure modes.
The solution: a different registrable domain
Section titled “The solution: a different registrable domain”SupaCloud serves hosted App frontends on supacloud.net, a domain with a
different eTLD+1 from the control-plane domain (supacloud.run). This makes
every hosted App cross-site to the control plane. With the existing cookie
configuration, the session-riding closes automatically:
SameSite=Laxwithholds the session cookie on cross-site requests from the app to/api.- Cross-eTLD+1 rules prohibit a
Domainattribute that would span both domains, so cookie-tossing is structurally impossible. - The control plane’s exact-allowlist CORS policy naturally excludes the apps domain; no CORS relaxation is needed.
What shipped (issue #410 / ADR 0041)
Section titled “What shipped (issue #410 / ADR 0041)”SUPACLOUD_APPS_BASE_DOMAIN — set to supacloud.net on the hosted
deployment. This is the only configuration required on the SupaCloud side.
Boot guard (secret_hydration.rs) — SupaCloud refuses to start if the
apps domain shares an eTLD+1 with the control-plane host. The psl crate
compares registrable domains; equal, subset, or sibling-subdomain
relationships are all rejected.
SUPACLOUD_APPS_HOSTED_FRONTEND=true additionally requires
SUPACLOUD_APPS_BASE_DOMAIN to be set; the guard enforces this at boot so
the hosted path can never be enabled without the isolation boundary in place.
Per-deployment opaque subdomain (migration 172) — each deployed hosted
App receives a stable 80-bit hex subdomain (e.g. a3f8b2….supacloud.net),
assigned once and stable across redeploys. Only hosted frontends (react,
svelte, plain-with-source) receive a subdomain; form-renderer Apps are
served only on the control-plane path and are unaffected by this change.
Host-dispatch middleware (interfaces/http/apps_host.rs) — mounted as
the outermost layer of the axum router. A request whose Host matches
supacloud.net or a subdomain of it is served as App content and never
reaches the control plane — no /api routes, no SPA, no login page.
Only GET and HEAD are accepted; all other methods are rejected with
405 Method Not Allowed.
Path route isolation (services/app_deployments/serve.rs) — when an
apps domain is configured, the control-plane path route
(/a/{workspace_slug}/{route_path}) refuses hosted frontends. A hosted App
is reachable only on its separate-origin subdomain. The #113 form-renderer
App (manifest-only plain App) is not a hosted frontend and continues to be
served on the path route.
Per-App callback token (migration 173, prefix scwa_) — a server-side,
owner-issued bearer token for App→/api callbacks. Because the App’s JS
cannot carry the operator session cookie cross-origin, App backends that need
to call the SupaCloud API use this token instead. It is minted via
POST /api/apps/{id}/deployment/token, is workspace-scoped, and is revoked
automatically on undeploy. The token is used server-side and is never embedded
in the public frontend.
Summary
Section titled “Summary”| Control | Closes | Notes |
|---|---|---|
Separate registrable domain (supacloud.net) |
Session-riding via SameSite=Lax |
Primary isolation |
| Cross-eTLD+1 | Cookie-tossing / shadow-cookie attack | Structural; no code required |
| Boot guard (eTLD+1 check) | Misconfigured same-site apps domain | Fail-closed at startup |
| Host-dispatch middleware | Hosted App reaching /api routes |
GET/HEAD only |
| Path-route refusal | Hosted App on control-plane origin | Form-renderer exempt |
| Per-App callback token | App→API identity without session | Owner-issued, hash-only |
For operational setup, see Configuring App hosting and the Environment variables reference. The full design rationale is in ADR 0041.