Auth planes & tenancy
This page expands the auth and tenancy parts of the API contract into the details that bite in practice.
The login and refresh flow
Section titled “The login and refresh flow”- The client logs in on its plane. On the tenant plane it must send a
Tenant-IDheader on the login request itself. - A 2FA-enrolled login returns
200 { requiresTwoFactor: true, twoFactorToken }with no access token; the client posts the TOTP or recovery code to/login/2fa. - On success the client receives an access token (kept in memory) and a refresh cookie is set (httpOnly,
Secure,SameSite=Strict, path-scoped to the plane). - On a
401, the client silently calls/refresh(single-flight) withcredentials: include, gets a fresh access token, and retries. /logoutrevokes the refresh token and clears the cookie.
Because the refresh token is a cookie and the access token is in memory, a session survives a page reload (the app re-runs the refresh on boot) but a token cannot leak through localStorage.
The same-origin constraint
Section titled “The same-origin constraint”SameSite=Strict plus path-scoping means the browser only returns the refresh cookie to the same origin, on the right path. In practice:
- A dashboard and the API it calls must be served from one origin. The tenant dashboard reaches the API under
/api; the admin dashboard under/admin. - The API has no CORS - a cross-origin dashboard simply would not work.
This is the reason the deployment topology puts the API on the same host and domain as the dashboards. See Deploying.
The cookie-path gotcha
Section titled “The cookie-path gotcha”The tenant dashboard calls /api/identity/*, a reverse proxy strips /api, and the backend sees /identity. Left alone, the backend would set the refresh cookie at Path=/identity, but the browser needs it at Path=/api/identity - so the session dies on reload.
Two ways this is solved, depending on the proxy:
- Caddy can rewrite the
Set-Cookiepath directly (header_down). - Traefik (used by Dokploy) cannot rewrite a
Set-Cookiepath. Instead the backend emits the cookie at its public path via theAuth__TenantCookiePath/Auth__AdminCookiePathconfig values. This works under any proxy and needs no plugin.
Password rules are ASCII-only
Section titled “Password rules are ASCII-only”The backend uses ASP.NET Core Identity’s default password validator, which checks character classes with ASCII ranges (A-Z, a-z, 0-9) - not Unicode-aware character categories. Any client-side password strength check must use the same ASCII classes, or it will accept passwords the server then rejects.
Tenancy tiers
Section titled “Tenancy tiers”Tenants live in one of two isolation tiers, chosen for cost versus isolation:
- Shared - a schema per tenant inside a shared database. Each shared tenant connects as its own restricted PostgreSQL role, so isolation is enforced by the database, not just by routing.
- Dedicated - a database of its own, also connected via a least-privilege role.
A shared tenant can be graduated to dedicated (optionally onto a different server) by an in-process binary copy, with a security fence that stops in-flight connections before the snapshot and restores the tenant’s prior active state on completion. Placement across multiple servers is handled by a server pool. The deep design lives in backend ADR-0022; the runbooks cover the operational side.