{
  "docId": "baas.iam.authn.realms",
  "title": "Realms",
  "summary": "How iam.svc realms bind a schema entity to a set of providers, how a realm is resolved on a login request, and how login-identifier columns are declared.",
  "url": "https://docs.kis.ai/blocks/baas/iam/authn/realms/",
  "markdown": "https://docs.kis.ai/blocks/baas/iam/authn/realms.md",
  "product": "iam",
  "area": "guide",
  "documentType": "guide",
  "status": "active",
  "authority": "canonical",
  "visibility": "public",
  "documentationVersion": "2026.07",
  "productVersion": "2.0.0",
  "apiContractVersion": null,
  "httpPathVersioning": "none",
  "intent": [],
  "audience": [],
  "lastModified": "2026-07-29",
  "lastReviewed": null,
  "owners": [],
  "appliesTo": [
    ">=2.0.0 <2.1.0"
  ],
  "prerequisites": [],
  "related": [],
  "supersedes": [],
  "supersededBy": null,
  "ai": {
    "discoverable": true,
    "retrievable": true,
    "authoritative": true,
    "chunking": "heading",
    "answerableQuestions": []
  },
  "headings": [
    {
      "depth": 2,
      "text": "realms.yaml",
      "id": "realmsyaml"
    },
    {
      "depth": 2,
      "text": "Binding an entity",
      "id": "binding-an-entity"
    },
    {
      "depth": 2,
      "text": "Composition and precedence",
      "id": "composition-and-precedence"
    },
    {
      "depth": 2,
      "text": "Resolution order",
      "id": "resolution-order"
    },
    {
      "depth": 2,
      "text": "The provider gate",
      "id": "the-provider-gate"
    },
    {
      "depth": 2,
      "text": "Login identifiers",
      "id": "login-identifiers"
    },
    {
      "depth": 3,
      "text": "Resolution",
      "id": "resolution"
    },
    {
      "depth": 3,
      "text": "Fallback",
      "id": "fallback"
    },
    {
      "depth": 3,
      "text": "Base identifiers",
      "id": "base-identifiers"
    },
    {
      "depth": 2,
      "text": "The reserved superadmin realm",
      "id": "the-reserved-superadmin-realm"
    }
  ],
  "wordCount": 1548,
  "body": "A realm names the schema entity that holds a set of identities and the provider names allowed to\nauthenticate against it. Realms are configuration composed per tenant — there is no `realm` table,\nno `/admin/realm` route, and no discovery endpoint. A client must know its realm name out of band.\n\n`iam.svc` ships one built-in realm:\n\n| Realm | Entity | Active | Default | Providers |\n|-------|--------|--------|---------|-----------|\n| `users` | `users` | yes | yes | `password` |\n\nEvery layer you add composes over that.\n\n## `realms.yaml`\n\n`realms:` is a **map keyed by realm name**, not a list.\n\n```yaml\n# iam/realms.yaml\nrealms:\n  users:\n    default: true\n    active: true\n    providers:\n      - password\n      - magiclink\n  patient:\n    active: true\n    entity: patient          # your entity, from iam/extend/patient.yaml\n    providers:\n      - password\n```\n\n| Key | Type | Default | Meaning |\n|-----|------|---------|---------|\n| `realms.<name>.entity` | string | `users` | Schema entity holding this realm's identities. Empty resolves to the base `users` entity. |\n| `realms.<name>.active` | bool | `true` when absent | An inactive realm refuses login with `400 unknown_realm`. |\n| `realms.<name>.default` | bool | `false` | Marks the realm used when a login request names none. |\n| `realms.<name>.providers` | list of string | `[]` | Provider names this realm allows. An empty or absent list means password-only. |\n\nThe same file may also carry a `realmconfig.enable.agents` / `.bots` / `.delegations` block. Those\ntoggles are not realm settings — they remove the corresponding entities from the tenant's composed\nschema entirely.\n\n## Binding an entity\n\nThe realm's `entity` must exist in the tenant's composed schema (service base ⊕ product ⊕ tenant\nextensions). To authenticate against it the entity needs:\n\n- at least one field carrying `attributes: {useforauth: true}`, and\n- a field named `password`, which the engine argon2id-hashes on create and update.\n\nA login against `realm: patient` reads the `patient` entity, and the minted token carries\n`realm: \"patient\"` with `sub` set to the row id **in that entity** — not in `users`. Two realms over\ntwo entities are two disjoint identity populations that happen to share a tenant.\n\n## Composition and precedence\n\nRealms compose across three layers, last-write-wins **per named realm**:\n\n| Order | Layer | Source |\n|-------|-------|--------|\n| 1 | Service defaults | Built in — realm `users` over entity `users`, provider `password` |\n| 2 | Product | `iam/realms.yaml` |\n| 3 | Tenant | A `tenant_extensions` row at path `/realms.yaml` |\n\n:::caution\nMerging replaces a realm **whole**, not field by field. Redeclaring `users` in your product file\nwith only `entity:` set discards the built-in `providers: [password]` list — which happens to leave\npassword login working, because an empty list means password-only, but the same rewrite of a realm\nthat listed several providers silently drops all of them. Restate every key you want to keep.\n:::\n\nA realm cannot be deleted by a later layer, only redefined. Setting `active: false` is how you\nretire one. Because the service defaults are always layer 1, the realm named `users` is always\npresent in the composed map — a product cannot remove it, only redefine or deactivate it.\n\nThe default realm is whichever realm was last seen carrying `default: true`. A layer that declares\nrealms but marks none of them default leaves the previous layer's choice standing.\n\n:::caution\nDeclare `default: true` on exactly one realm per file. Realms within one file are folded in map\norder, so if two realms in the same file both claim it, which one wins is not deterministic across\nboots.\n:::\n\n## Resolution order\n\n`POST /auth/login` resolves the realm before it looks at any credential:\n\n1. The request's `realm` field, if present. A value equal to `superadmin` — compared\n   case-insensitively after trimming — short-circuits the entire realm machinery and authenticates\n   against the control plane instead.\n2. Otherwise the realm marked `default: true`.\n3. Otherwise the literal `users`.\n\nThe resolved name is then looked up in the composed map. A name that is absent, or present but\ninactive, fails. An empty `entity` on the resolved realm becomes `users`.\n\nSteps 2 and 3 are name fallbacks, not escape hatches — the name they produce is looked up and\ngated like any other. Redeclaring `users` as `active: false` without marking another realm\n`default: true` makes every login that omits `realm` fail with `400 unknown_realm`.\n\n```http\nPOST /auth/login\n```\n\n```json\n{ \"identity\": \"user@acme.io\",\n  \"password\": \"…\",\n  \"identity_type\": \"mobile\",\n  \"realm\": \"patient\" }\n```\n\nErrors from realm resolution, in the standard `{\"code\": …, \"error\": …}` envelope:\n\n| Code | HTTP | When |\n|------|------|------|\n| `unknown_realm` | 400 | The named realm is not in the composed map. Message ends `unknown realm \"<name>\"`. |\n| `unknown_realm` | 400 | The realm exists but is inactive. Message ends `realm \"<name>\" is inactive`. |\n| `unknown_realm` | 401 | On `POST /auth/mfa/verify` only — the realm carried by the `mfa_token` no longer resolves. Message: `realm no longer exists`. |\n| `provider_not_allowed` | 403 | The realm's `providers` list does not contain `password`. |\n| `bad_identity_type` | 400 | `identity_type` names a column the entity did not declare as a login identifier. |\n\nRealm survives the flow: [refresh](/blocks/baas/iam/tokens/) preserves the realm the session started\nin, and the [MFA-pending token](/blocks/baas/iam/authn/mfa/) carries it so the second step reads the\nsame entity. The full password flow is on [Password login](/blocks/baas/iam/authn/password/).\n\n## The provider gate\n\n`providers:` is a name gate. A provider must be declared in `iam/providers.yaml` for a realm to\nreference it, but the declaration carries exactly three fields — `name`, `type`, `template` — and no\ncredentials, endpoints or callback URLs.\n\n:::caution\nThe provider gate is consulted by **password login only**, and only ever with the literal string\n`password`. [Magic link and WebAuthn](/blocks/baas/iam/authn/passwordless/) and\n[OAuth](/blocks/baas/iam/authn/oauth/) never consult a realm at all: they hard-code the `users`\nrealm and query the base `users` entity by email address. Adding or removing `magiclink`, `o365` or\nany other name from a realm's `providers` list changes nothing for those flows. A realm whose list\nis `[password]` still permits magic-link login for any row in the base `users` table.\n:::\n\nNothing reads `type` or `template` at request time either, so `type: oauth` on a declared provider\nwires no identity provider. The consequence for the realm author is narrow and worth stating\nplainly: `providers` decides one thing — whether password login against this realm returns\n`403 provider_not_allowed`.\n\n## Login identifiers\n\nWhich columns can identify a user is declared **on the field**, in the entity definition, not in\n`realms.yaml`:\n\n```yaml\nfields:\n  - name: email\n    type: string\n    attributes:\n      useforauth: true        # this column is a login identifier\n      useforidentity: true    # …and the default one, tried first\n  - name: mobile\n    type: string\n    attributes:\n      useforauth: true\n  - name: employeeid\n    type: string\n    attributes:\n      useforauth: true\n```\n\n| Attribute | Effect |\n|-----------|--------|\n| `useforauth: true` | The column may be matched against the request's `identity` value. |\n| `useforidentity: true` | Among the `useforauth` columns, this is the one tried first. |\n\nAccepted truthy spellings: boolean `true`, or the strings `\"true\"`, `\"yes\"`, `\"1\"`. Anything else\nreads as false.\n\nBecause the declaration lives on the field, it composes through the same layering as the rest of the\nschema — a product or tenant that adds an `employeeid` field with `useforauth: true` has added a\nlogin identifier without touching the auth configuration. See\n[Identity entities](/blocks/baas/iam/identity/entities/) for what a layer may add and what the\n`final` and `access-lock` seals refuse.\n\n### Resolution\n\n1. The `useforidentity` column, if one is declared.\n2. The remaining `useforauth` columns, in declared field position order.\n\nWith no `identity_type` in the request, each column is tried in that order until one matches. A\nfailure at any column returns the same `401 invalid_credentials` — which column missed is never\ndisclosed.\n\nSending `identity_type` narrows the lookup to exactly one column. The value is lower-cased and\ntrimmed, then matched against the declared set. A name that is not in that set returns\n`400 bad_identity_type`, and the message enumerates what **is** declared:\n\n```json\n{ \"code\": \"bad_identity_type\",\n  \"error\": \"identity_type \\\"username\\\" is not a declared login identifier (declared: email, mobile)\" }\n```\n\nThe service never filters on a column the entity did not mark.\n\n### Fallback\n\nAn entity that declares no `useforauth` column at all falls back to `[\"email\"]`. The same fallback\napplies when the composed schema cannot be resolved for the tenant. A minimal product realm entity\ntherefore logs in by email whether or not it says so.\n\n### Base identifiers\n\n| Entity | Plane | `useforidentity` | Other `useforauth` |\n|--------|-------|------------------|--------------------|\n| `users` | tenant | `email` | `mobile` |\n| `superadmin` | control | `email` | `mobile` |\n\nBoth columns carry a unique constraint; `email` is additionally required, `mobile` is not.\n\n## The reserved `superadmin` realm\n\n`realm: \"superadmin\"` is not a realm you can declare. It is intercepted before tenant resolution and\nauthenticates against the control plane's own `superadmin` entity, signed by that plane's own\nkeyring, with `roles: [\"superadmin\"]` derived structurally from the path and never from row data.\nIt bypasses realm resolution, the composed behavior config and the provider gate entirely, and it\ncarries no MFA and no lockout policy. Declaring a realm named `superadmin` in `realms.yaml` has no\neffect — the interception happens first. The operator plane is covered on\n[Superadmin](/blocks/baas/iam/authz/superadmin/).\n\nEvery request and response shape for these routes is in the [IAM API reference](/blocks/baas/iam/api/); the full\nerror table is on the [error page](/blocks/baas/iam/error/)."
}