Skip to content

Tenants & multitenancy

A tenant is a validated, immutable slug — the slug is the identity in URLs, storage keys, and issuers, with no opaque internal id. The product word is tenant (the earlier “realm” spelling survives only as the deprecated [realms] configuration alias).

Property Rule
Slug grammar Two to sixty-three characters, starting with a lowercase letter, then lowercase letters, digits, or hyphens.
Immutability A tenant is never renamed — a rename would change the issuer of every token the tenant minted.
Default tenant default, the implicit tenant every deployment has; un-segmented URLs, the deployment issuer, and legacy flat keys belong to it. It can be read and updated like any other but never deleted and never re-created.
Issuer Default tenant: <scheme>://<public_url>/auth/v1/ (unchanged). Any other tenant: <scheme>://<public_url>/auth/v1/{slug}, or https://<primary hostname>/auth/v1 once it has a hostname.
Storage Every tenant-scoped key family is namespaced under tenants/{slug}/…; the default tenant’s keys keep the un-segmented layout through a dual-read decorator, with a one-shot legacy migration at boot.

[tenants] enabled = true switches on the path-bound tenant machinery (/auth/v1/{tenant}/...); while it is off (the default), every request resolves to the implicit default tenant and behavior is byte-identical to a single-tenant deployment. The tenant administration surface is mounted unconditionally — it is the platform’s own administration path.

Give each customer its own tenant. A tenant is a separate trust domain: its own issuer, signing keys, accounts, sessions, clients and identity providers. An application that serves several customers binds each of its own tenants to one TinyGuard issuer, and never reads the customer from a claim: the issuer is the customer. Tokens carry no tenant claim.

Inside one customer’s tenant, the usual set-up is TinyGuard as the broker: the customer’s employees sign in through their company directory (Microsoft Entra ID, Okta, Google Workspace, …), the customer’s own customers have TinyGuard accounts, and the application tells the two apart by group. Federation walks through it step by step.

A person has one home tenant: the one that owns their e-mail domain, or the one that invited them. Sessions never cross tenants, and TinyGuard never links accounts across tenants. When an application needs the same person in several of its tenants (a consultant who works for two customers, a support engineer), the application invites that identity (issuer and subject) into its other tenants and offers its own tenant switcher after sign-in. Do not create a second TinyGuard account for the same person in another tenant to give them access: they would be a different person there, with another password and another subject.

Sign-in domains, the e-mail step and the shared sign-in page apply only when the deployment serves several tenants. With [tenants] enabled = false, or with only the default tenant, the login page stays as it is, there is no shared sign-in page, the sign-in domain API answers 409 (“sign-in domains are used only when this deployment serves several tenants”) and TinyGuard makes no DNS lookups.

Concern Scope Notes
Accounts Per tenant Address uniqueness, creation index, and write-time reference filtering resolve inside the tenant; the same address may exist in two tenants.
Roles / groups registries Per tenant Each tenant carries independent registries with no shared entry beyond its own reserved administration role.
Sessions Per tenant Namespaced under the tenant.
Clients, passkeys, devices, events, email jobs Per tenant Namespaced key families.
Signing keys / JWKS Per tenant Each tenant’s trust domain; rotation iterates tenants.
Reserved admin role Per tenant tinyguard_admin, seeded into the tenant’s namespace at creation.
Platform administration Platform The platform_admin role is stored at the platform level, not under a tenant; it gates tenant lifecycle and cross-tenant reads.
Encryption (value cipher, secrets file) Shared Deployment-level; not externally visible.
Static deployment config Shared Listeners, TLS, proxy, storage, hashing, metrics, secrets stay file config.
Role Granted Powers
platform_admin Stored at the platform level Create, enumerate, suspend, activate, and delete tenants; cross-tenant reads; may read and update any tenant.
tinyguard_admin Seeded per tenant (grant via the tenant’s admin_emails) Read and update exactly its own tenant; a full administrator inside it. An administrator of one tenant reads no other: an identifier minted in another tenant’s namespace does not resolve.

Tenant administration is decided by the account the caller’s session resolves to inside the target tenant’s store — the same store that granted the session. Delegated administration (roles named tinyguard_admin:{group}) stays tenant-scoped.

Creation (POST /auth/v1/tenants with slug, optional display_name, admin_emails):

  1. The slug passes the grammar, is not default, and is not one of the reserved slugs; the slug must be free.
  2. The tenant’s reserved administration role is seeded into its namespace first — a grant cannot precede the role it names.
  3. Every listed administrator address is granted that role, with a minimal credential-less account created for an address the new tenant does not know (email_verified: true, password set through the reset path on first use). An address that exists only in another tenant gets a fresh account in the new one — no link crosses the tenant boundary. admin_emails is for the tenant’s own administrators, not a way to give someone from another customer access: see one person in several tenants.

Suspend / activate (POST .../suspend, POST .../activate): a suspended tenant keeps every record but refuses work. Platform gate only — a tenant cannot suspend itself out from under the platform’s deletion safety net.

Deletion (DELETE /auth/v1/tenants/{slug}):

  • Refused unless the tenant is suspended first (suspend-before-delete).
  • Refused while the tenant still holds accounts, unless the request passes force=true to purge it.
  • A forced deletion removes every namespaced key of the tenant (a listing plus one delete per key, through the storage port) and then the record itself.
  • The default tenant is never deletable.

Wire states: active, suspended, disabled. The single-tenant document carries slug, display_name, state, created_at, updated_at, and the derived admin_emails (the accounts holding the reserved role — never stored beside the record, so the listing cannot drift from the grant).

Every durable tenant write applies the stored record first and the live in-memory registry second, so the two never disagree for longer than one write.

The lifecycle routes are platform administration, and a platform administrator’s session is not something a Job or an operator can hold. They can hold an API key with the tenants grant, which authorises exactly the lifecycle routes, each by the right it needs:

Right Routes
read GET /auth/v1/tenants, GET /auth/v1/tenants/{slug}
create POST /auth/v1/tenants
update PUT /auth/v1/tenants/{slug}, POST …/suspend, POST …/activate
delete DELETE /auth/v1/tenants/{slug}

A key with read alone can list tenants and not create one; a key without the tenants grant gets 403 on all of them however much else it may do.

The key is created from the environment, so nothing needs a human to start:

Terminal window
# <name>$<secret>: the form a client presents. The secret is at least 32
# characters of letters, digits, - _ . ~
GUARD_BOOTSTRAP_PLATFORM_API_KEY='provisioner$<a long random secret>'
# or, from a mounted Secret:
GUARD_BOOTSTRAP_PLATFORM_API_KEY_FILE=/run/secrets/provisioner

On every boot the key is made to match: absent, it is created; present with the same secret, nothing is written; present with a different secret, the secret is replaced — so rotating the Secret rotates the credential. The seeded key holds the tenants grant and nothing else. Setting both variables is an error and seeds nothing.

Then a provisioner is one request per step:

Terminal window
AUTH="Authorization: API-Key provisioner\$<secret>"
curl -X POST "$ISSUER/auth/v1/tenants" -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"slug":"acme-corp","display_name":"Acme","admin_emails":["lead@acme.test"]}'

An API key is stored in, and authenticates against, the tenant whose route it is presented to. A key made at /auth/v1/<tenant>/api_keys works at that tenant’s routes and nowhere else, not in the default tenant and not in another tenant; a key made in the default tenant does not work inside a tenant. Listing, creating, updating, rotating and deleting keys through a tenant’s route reads and writes that tenant’s keys only. The keys that provision tenants live in the default tenant, because the lifecycle routes are the deployment’s own.

Platform access does not propagate. The tenants area can be granted only by a human administrator of the deployment’s own tenant (the console or POST /auth/v1/api_keys with a session) or by the bootstrap variable — never by a machine credential, so a key that may manage keys cannot mint itself a stronger one, and never from inside a tenant.

Treat the key as you would a platform administrator’s password: it can create, suspend and delete every tenant. Keep it in a Secret, give a Job a key with only the rights it needs, and rotate it by changing the Secret.

A tenant’s data is one key range — its record and everything under it — so it can be taken out, put back, or removed on its own, without touching any other tenant. That is what offboarding a customer, moving one between deployments, and restoring one from a backup need.

Terminal window
# Write one tenant's data to a file (JSON lines, values base64-encoded).
idp-server tenant export --tenant acme --output acme.jsonl \
--config config.toml --secrets secrets.toml
# Restore it. A tenant that already has data here is refused...
idp-server tenant import --input acme.jsonl --config config.toml --secrets secrets.toml
# ...unless you say the existing data should be replaced, in which case it is
# removed first, so the result is the export and not a blend of it and
# whatever came after.
idp-server tenant import --input acme.jsonl --replace --config config.toml --secrets secrets.toml
# Remove everything the tenant owns. It asks for --yes and says to export first.
idp-server tenant purge --tenant acme --yes --config config.toml --secrets secrets.toml

What the commands guarantee:

  • An export holds exactly one tenant. A tenant whose slug merely begins with another’s (acme and acme-corp) is never swept in.
  • An import cannot write outside its tenant. Every key in the file is checked against the tenant named in its header before anything is written, so a hand-edited export cannot reach another tenant or the platform’s own data. The same pass refuses a truncated file (the header carries the entry count), a duplicated entry, a bad value, and an unknown format or version. A refused import changes nothing.
  • The default tenant is never removed or replaced. It holds the platform’s own administrators.

Three things to know before you rely on them:

  • Sealed values are bound to the deployment’s encryption key. Provider credentials and similar values are encrypted with it. An export restores into a deployment that holds the same key; into one that does not, those values come back unreadable and must be entered again.
  • These act on storage directly, like the other operator commands. A running server keeps in-memory caches, so run them against a quiesced deployment or restart the instances afterwards.
  • An import is not atomic. If it is interrupted part-way the tenant is partial; running the same import again with --replace completes it.

On PostgreSQL the database also answers per tenant: each tenant is an indexed column, so how much a tenant stores is a query rather than a scan of every key.

Mapping a customer to an application tenant

Section titled “Mapping a customer to an application tenant”

Each customer is a TinyGuard tenant with its own issuer, and an application binds each of its tenants to one issuer explicitly. Use one slug for everything: customer acme is the TinyGuard tenant acme (issuer https://guard.example.com/auth/v1/acme, or https://acme.guard.example.com/auth/v1 with a tenant hostname), the application’s tenant acme, and the name the application gives that identity provider. Keep the list per customer in the platform’s provisioning source, not in anyone’s head, and follow the reserved slugs below.

Inside a tenant, groups tell kinds of people apart (employees and customers in the broker set-up), not customers. The groups claim is released only when the client requests the groups scope; roles is always present.

A tenant can claim an e-mail domain, such as contoso.com, so that people with an address at that domain go straight to the right sign-in: the company’s identity provider, the tenant’s password page, or a choice between the two. A domain does nothing until the tenant proves it owns it with a DNS record.

Claiming. A domain belongs to at most one tenant in the deployment, and the first claim holds. Another tenant that tries to claim it gets 409 “this domain is not available”, whoever holds it. Domains are stored lower-case (international names in their xn-- form); IP addresses, single names such as localhost, and public suffixes such as co.uk are refused.

Proving it with DNS. Each claim gets its own value. Publish it as a TXT record:

Type Name Value
TXT _tinyguard-challenge.contoso.com tinyguard-verification=<the value shown for the claim>

Other TXT values at that name are ignored, so the record can sit beside others. TinyGuard looks the record up right after the claim, then every 15 minutes, and whenever an administrator presses Check now (at most once a minute). The lookup is the only network traffic involved: nothing is fetched from the domain’s websites. Once the record is found the domain is verified and starts routing people.

While a domain is not verified it routes nobody anywhere, passes on no hints, and is visible only to the tenant’s and the platform’s administrators. A claim that is not proven within [tenants] domain_claim_days (14 by default) is released. A domain that nobody can prove, such as gmail.com, can be claimed but never routes, and lapses after that period.

Keeping it. A verified domain is checked again every [tenants] domain_recheck_hours (24 by default). If the record is gone, the domain keeps working for [tenants] domain_grace_days (14 by default) while the console warns the tenant; after that it stops routing, gets a new claim period in which publishing the record restores it, and is then released. A DNS server that does not answer is not counted as a missing record.

Taking over a squatted domain. If another tenant claimed your domain but never proved it, claim it with "take_over": true. You get your own value; publish it, and when your record is found first the domain moves to you. The former holder’s event log says it was released because another tenant proved it, without naming you. A verified domain cannot be taken over: only the platform administrator can move it.

Without outbound DNS. A platform administrator can mark a domain verified without the DNS check, giving a reason. Such a domain is not re-checked.

Where a domain sends people. Each domain has a target:

Target What happens to ana@contoso.com
{"kind": "provider", "provider": "contoso-entra"} The login page is skipped: she goes straight to the tenant’s provider contoso-entra, with her address passed on.
{"kind": "password"} She gets the tenant’s password page.
{"kind": "choice", "providers": ["contoso-okta"], "password": true} She chooses between the listed providers and Use my password. For example: staff use Okta, and contractors at the same domain have passwords.

Changing a target needs no new proof. A provider that is disabled or deleted drops out of a target: a provider target without its provider becomes the password page, and a choice left with one option behaves as that option. Only the tenant’s own enabled providers are ever offered. See Authentication for what the person sees.

API. A tenant’s administrators manage its own domains; an API key needs the sign_in_domains area (read, update).

Method and path What it does
GET /auth/v1[/{tenant}]/sign_in_domains This tenant’s domains, with state and the DNS record to publish.
PUT /auth/v1[/{tenant}]/sign_in_domains/{domain} Claim a domain with {"target": …} (optionally "take_over": true), or change its target.
POST /auth/v1[/{tenant}]/sign_in_domains/{domain}/verification Check the DNS record now.
DELETE /auth/v1[/{tenant}]/sign_in_domains/{domain} Release the domain.
GET /auth/v1/tenants/sign_in_domains Every tenant’s domains (platform administrators).
POST /auth/v1/tenants/{slug}/sign_in_domains/{domain}/verification With {"override": "VERIFIED", "reason": "…"}: mark it verified without DNS (platform administrators).
POST /auth/v1/tenants/sign_in_domains/{domain}/move With {"to": "<slug>", "reason": "…"}: move a domain to another tenant (platform administrators).

Deleting a tenant releases its domains. Set [tenants] dns_resolvers to ask particular DNS servers instead of the system’s; a validating resolver is a good choice. Claims, proofs, failures, expiries, takeovers and releases are written to the tenant’s event log.

A deployment that serves several tenants has one page for people who do not know their tenant’s own address: https://guard.example.com/auth/v1/sign-in. It asks for an e-mail address and nothing else. On Continue:

  • an address at a verified domain of an active tenant goes to that tenant’s sign-in, with the address passed on as login_hint (/auth/v1/contoso/?login_hint=ana%40contoso.com). The tenant’s own page then does what the domain’s target says;
  • every other address (a domain nobody claimed, one that is not proven yet, one of a suspended tenant) goes to the default tenant’s sign-in, also with the address.

The page never lists tenants, and its answer never depends on whether an account exists. What someone can learn from it is where a verified domain sends people, which the domain’s owner chose by claiming it. The address the browser is sent to is always built from the tenant’s registered name, never from anything in the request. The form is accepted only from the page itself, and at most 30 times a minute from one network address. Someone whose account is in a tenant that does not own their e-mail domain (a contractor with a personal address, for example) uses that tenant’s own address or hostname, which its invitations and applications carry.

On a deployment that serves one tenant the shared page does not exist: /auth/v1/sign-in sends the browser to /auth/v1/.

A tenant can also have its own hostname, so its people go straight to its sign-in with no e-mail step at all:

  • a subdomain of the deployment’s suffix, such as acme.guard.example.com with [tenants] hostname_suffix = "guard.example.com". One wildcard DNS record and one wildcard certificate cover every tenant, so a new tenant’s subdomain works at once, with no proof;
  • a custom hostname the customer owns, such as login.acme.com. It is proven with the same kind of DNS record as a sign-in domain (_tinyguard-challenge.login.acme.com), and the customer points it at the deployment with a CNAME.

A tenant has one primary hostname, which becomes its issuer (https://acme.guard.example.com/auth/v1), and any number of aliases that redirect to it. A tenant keeps exactly one issuer: once the primary hostname is active, the path form https://guard.example.com/auth/v1/acme redirects browsers there and stops answering token requests. Because the issuer is part of every application’s configuration and of every person’s identity there, set the primary hostname when you create the tenant, or while it has no applications yet. Callback addresses, SAML addresses, passkeys and e-mail links all follow the primary hostname, so passkeys registered under the shared address do not work on it.

Each hostname has its own cookies: a session made on acme.guard.example.com is never sent to another tenant’s hostname or to the shared address. Use a suffix that serves nothing but TinyGuard tenants. The platform administrator manages hostnames (/auth/v1/tenants/{slug}/hostnames/{host}); with [tenants] tenant_hostnames = true a tenant’s own administrators may manage its aliases and custom hostnames too. Certificates for custom hostnames come from the Helm chart (tenants.customHostnames, one cert-manager certificate each) or, when TinyGuard terminates TLS itself, from one <hostname>.crt/.key pair per hostname in [server] tls_cert_dir. The chart’s tenants.hostnameSuffix, tenants.wildcardTlsSecret and tenants.certManagerIssuer set up the wildcard; see Kubernetes.

Exporting and importing a tenant over the API

Section titled “Exporting and importing a tenant over the API”

For a running deployment, a tenant’s whole data moves over the API, with the tenant_data grant (or a platform administrator’s session):

Terminal window
# Export: JSON lines, a header then one line per key (values in base64).
curl -H "Authorization: API-Key mover\$<secret>" \
"$ISSUER/auth/v1/tenants/acme-corp/export" -o acme-corp.jsonl
# Import: the path names the tenant, and the file must be for it.
curl -X POST -H "Authorization: API-Key mover\$<secret>" --data-binary @acme-corp.jsonl \
"$ISSUER/auth/v1/tenants/acme-corp/import"

read exports and create imports. It is a separate grant from tenants because an export holds every record of the tenant, password hashes included, so a key that provisions tenants cannot dump them; like tenants, it can be granted only by a human administrator of the deployment’s own tenant. An import is refused if the tenant already has data. ?replace=true replaces it, and only once the tenant is suspended, so live users are never overwritten. The default tenant cannot be imported over. The imported tenant routes immediately, without a restart. Sealed values (secrets) are encrypted with the deployment’s key, so an export restores only into a deployment that holds the same one. The body is read in full before anything is written (limit 256 MiB), so a refused import changes nothing. The same operations exist as idp-server tenant export | import | purge for a stopped deployment.

A tenant slug must not shadow a first-level segment of the versioned tree; the collision is refused at creation and at import:

ping ready health version whoami docs events email clients oidc pow users
password_policy login_time password_hash_times roles groups scopes api_keys
sessions console blacklist tenants assets sign-in sign_in_domains hostnames

sign-in is the shared sign-in page. hostnames is reserved because /auth/v1/tenants/hostnames lists every tenant hostname, so a tenant of that name could not be read at /auth/v1/tenants/{slug}. The server refuses to start while a stored tenant uses a reserved slug. Rename such a tenant before upgrading: export it, import the export under another slug, and delete the old one.

The console’s Tenants section (platform administrators only) lists tenants with state and administrator addresses, and performs creation, edit, the suspend/activate toggle, and force-delete through write dialogs. The Sign-in domains section lists the selected tenant’s domains with their state and the DNS record to publish. See the console guide.