Concepts
Platform vs. customer tenants
Authio runs its own admin console on a tenant that is structurally separate from the customer tenant model. Same database, different rules.
Like every multi-tenant platform we host, Authio has a control surface of its own — the operator console at admin.authio.com, the dashboard_operators allow-list, the signing keys we use to mint platform-mode tokens. WorkOS, Auth0, and Stytch all isolate that internal surface from the customer tenant model. We do too. This page explains how, and why customers should care.
Two surfaces, two URLs, one identity. dashboard.authio.com is the customer dashboard — you sign in there to operate your own Authio tenant. We also use it ourselves when we run Authio for our own apps (yes, Authio is a customer of Authio, the same way Stripe takes payments through Stripe). admin.authio.com is the staff operator console and is independent: same database, different cookie scope, different URL flow, different JWT shape. The two sessions don’t interfere — the same Authio employee can have a tenant on the customer dashboard AND an operator session on the admin app at the same time. The surface is determined by which URL you signed in through, not by who you are.
Short version. Authio HQ is a tenant in the same tenants table as your tenant, but it carries kind = 'platform' — a flag introduced by migration 0058_platform_tenant.sql. Plan limits don’t apply to it, the billing service refuses to mint a Stripe customer for it, its admin tokens carry a separate kind: "platform" JWT claim, and customer-tenant API routes refuse those tokens.
Why we did this
Pre-2026-05-22, the row backing Authio HQ in our database was doing double duty: it was both the project that gated which Authio employees could load admin.authio.com, and a regular customer tenant subject to plan limits, billing rules, and audit retention caps. This caused four classes of problem:
- Billing. The platform doesn’t pay itself. A free-plan platform tenant counted toward the owner’s
max_tenantsquota. - Plan limits. Caps that make sense for a customer (1 SSO connection, 1000 MAU, 7-day audit retention) are nonsensical for the platform itself.
- Signing keys. A customer’s 90-day rotation cadence has a different blast radius than the platform’s own keys.
- Audit. Admin actions against tenants and tenant actions against users have different retention obligations and should never share a stream.
What changed
We added a tenants.kind column with two values: customer (default for every paying / free-tier customer tenant; this is what you have) and platform (the single Authio HQ tenant; this is us). A partial unique index makes “exactly one platform tenant” an unkillable schema-level guarantee.
The boundary is enforced at four layers:
- Schema. The partial unique index
tenants_one_platformphysically prevents a secondkind='platform'row. A trigger ondashboard_operatorsrefuses to attach an operator row to a project owned by a customer tenant. - Plan-limit checks.
internal/billing/limits.goshort-circuits withAllowed=true, Limit=-1for the platform tenant BEFORE plan resolution — an explicit security boundary, not a silent bypass. - Billing service. Every entry point (
ensureStripeCustomer,createCheckoutSession, the usage roll-up worker) refuses the platform tenant. The platform never gets a Stripe customer ID and never appears on a Stripe subscription. - JWT claims. Tokens minted through the
admin.authio.comsign-in flow carry a top-levelkind: "platform"claim. Tokens minted throughdashboard.authio.comnever carry that claim (regardless of who the signing user is). Customer-tenant API routes refuse akind=platformtoken with a structured403 platform_token_not_allowed. The boundary is URL-flow-driven, not identity-driven: an Authio employee who signs in on the customer dashboard gets a normal customer-shape token.
Why this matters to you
Two practical guarantees:
- The platform we run cannot be used to drive your data. A platform-mode admin token cannot pass the customer-tenant API gate. The reverse is also true — a customer’s sk_live_ key from your tenant cannot bump plans, list operators, or otherwise touch the admin surface.
- Your plan limits are about you. They’re computed off
kind='customer'rows only. The fact that we run our own internal tenant in the same database has zero effect on yourmax_tenants,max_sso_connections, or any other cap.
Customer tenant only. Everything the rest of this documentation site refers to (SSO, SCIM, FGA, custom domains, branded email, plan limits, billing …) is customer-tenant-only behaviour. The platform tenant has its own rules and is invisible to you on the dashboard’s tenant switcher.
The boundary in the data model
Every Authio service that needs to ask “is this the platform tenant?” goes through a single helper:
authio_management-api→routes/_platform_tenant.tsauthio_billing→src/platform_tenant.tsauthio_auth-core→internal/store/tenants.go
We deliberately do not hardcode the platform tenant’s id anywhere outside the migration backfill. Every gate reads from the helpers, which read from tenants.kind. If we ever need to rotate the platform tenant’s row, it’s a single UPDATE; no config drift, no code grep.
Further reading
- Architecture record:
authio_compliance/architecture/platform-tenant-separation-2026-05-22.md(internal). - Migration:
authio_auth-core/migrations/0058_platform_tenant.sql.
