Skip to content

Auth planes & tenancy

This page expands the auth and tenancy parts of the API contract into the details that bite in practice.

  1. The client logs in on its plane. On the tenant plane it must send a Tenant-ID header on the login request itself.
  2. A 2FA-enrolled login returns 200 { requiresTwoFactor: true, twoFactorToken } with no access token; the client posts the TOTP or recovery code to /login/2fa.
  3. 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).
  4. On a 401, the client silently calls /refresh (single-flight) with credentials: include, gets a fresh access token, and retries.
  5. /logout revokes 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.

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 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-Cookie path directly (header_down).
  • Traefik (used by Dokploy) cannot rewrite a Set-Cookie path. Instead the backend emits the cookie at its public path via the Auth__TenantCookiePath / Auth__AdminCookiePath config values. This works under any proxy and needs no plugin.

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.

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.