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.
One tenant per customer
Section titled “One tenant per customer”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.
One person in several tenants
Section titled “One person in several tenants”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.
A deployment that serves one tenant
Section titled “A deployment that serves one tenant”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.
What is per-tenant vs platform
Section titled “What is per-tenant vs platform”| 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. |
Administration roles
Section titled “Administration roles”| 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.
Lifecycle
Section titled “Lifecycle”Creation (POST /auth/v1/tenants with slug, optional display_name,
admin_emails):
- The slug passes the grammar, is not
default, and is not one of the reserved slugs; the slug must be free. - The tenant’s reserved administration role is seeded into its namespace first — a grant cannot precede the role it names.
- 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_emailsis 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=trueto 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.
Provisioning without a session
Section titled “Provisioning without a session”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 first key
Section titled “The first key”The key is created from the environment, so nothing needs a human to start:
# <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/provisionerOn 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:
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"]}'Keys belong to a tenant
Section titled “Keys belong to a tenant”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.
What cannot delegate it
Section titled “What cannot delegate it”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.
Moving, restoring and removing one tenant
Section titled “Moving, restoring and removing one tenant”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.
# 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.tomlWhat the commands guarantee:
- An export holds exactly one tenant. A tenant whose slug merely begins
with another’s (
acmeandacme-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
defaulttenant 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
--replacecompletes 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.
Sign-in domains
Section titled “Sign-in domains”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.
The shared sign-in page
Section titled “The shared sign-in page”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/.
Tenant hostnames
Section titled “Tenant hostnames”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.comwith[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):
# 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.
Reserved slugs
Section titled “Reserved slugs”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 userspassword_policy login_time password_hash_times roles groups scopes api_keyssessions console blacklist tenants assets sign-in sign_in_domains hostnamessign-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.
Console surface
Section titled “Console surface”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.