{
  "docId": "baas.iam.authn.password",
  "title": "Password login",
  "summary": "The password credential flow on iam.svc — routes, bodies, the realm provider gate, argon2id verification, rate limits, password reset, the providers.yaml declaration, and every error code.",
  "url": "https://docs.kis.ai/blocks/baas/iam/authn/password/",
  "markdown": "https://docs.kis.ai/blocks/baas/iam/authn/password.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": "Routes",
      "id": "routes"
    },
    {
      "depth": 2,
      "text": "What a login does, in order",
      "id": "what-a-login-does-in-order"
    },
    {
      "depth": 2,
      "text": "Login request",
      "id": "login-request"
    },
    {
      "depth": 3,
      "text": "Identifier resolution",
      "id": "identifier-resolution"
    },
    {
      "depth": 3,
      "text": "The provider gate",
      "id": "the-provider-gate"
    },
    {
      "depth": 3,
      "text": "What login does not check",
      "id": "what-login-does-not-check"
    },
    {
      "depth": 2,
      "text": "Password storage and verification",
      "id": "password-storage-and-verification"
    },
    {
      "depth": 3,
      "text": "Password policy",
      "id": "password-policy"
    },
    {
      "depth": 2,
      "text": "Responses",
      "id": "responses"
    },
    {
      "depth": 3,
      "text": "Stateless tenant (the standard preset)",
      "id": "stateless-tenant-the-standard-preset"
    },
    {
      "depth": 3,
      "text": "MFA enrolled",
      "id": "mfa-enrolled"
    },
    {
      "depth": 3,
      "text": "Managed-session tenant (the hipaa and fedramp presets)",
      "id": "managed-session-tenant-the-hipaa-and-fedramp-presets"
    },
    {
      "depth": 2,
      "text": "Rate limiting",
      "id": "rate-limiting"
    },
    {
      "depth": 2,
      "text": "Event hooks",
      "id": "event-hooks"
    },
    {
      "depth": 2,
      "text": "Password reset",
      "id": "password-reset"
    },
    {
      "depth": 3,
      "text": "Request",
      "id": "request"
    },
    {
      "depth": 3,
      "text": "Confirm",
      "id": "confirm"
    },
    {
      "depth": 2,
      "text": "The providers.yaml declaration",
      "id": "the-providersyaml-declaration"
    },
    {
      "depth": 2,
      "text": "Error codes",
      "id": "error-codes"
    },
    {
      "depth": 3,
      "text": "POST /auth/login",
      "id": "post-authlogin"
    },
    {
      "depth": 3,
      "text": "POST /auth/password/reset/request",
      "id": "post-authpasswordresetrequest"
    },
    {
      "depth": 3,
      "text": "POST /auth/password/reset/confirm",
      "id": "post-authpasswordresetconfirm"
    },
    {
      "depth": 2,
      "text": "Worked example",
      "id": "worked-example"
    },
    {
      "depth": 2,
      "text": "Continue with",
      "id": "continue-with"
    }
  ],
  "wordCount": 2985,
  "body": "Password is the only credential flow in `iam.svc` that is realm-aware: it resolves a realm, checks\nthat realm's provider gate, looks the identity up in that realm's entity, and verifies an argon2id\nhash. Every other login flow authenticates against the base `users` entity in the default realm.\n\n## Routes\n\n| Method | Path | Auth | Rate-limit profile |\n|---|---|---|---|\n| `POST` | `/auth/login` | public | `auth_login` |\n| `POST` | `/auth/password/reset/request` | public | `auth_reset` |\n| `POST` | `/auth/password/reset/confirm` | public (the code is the credential) | `auth_reset` |\n\nAll three require the four tenancy headers — `X-Customer`, `X-Product`, `X-Env`, `X-Tenant`. Without\nthem the request fails with `400 no_tenant` before any credential is read. There is no `/v2` prefix\non any route.\n\nThere is no self-service change-password endpoint and no self-service signup. Writing the `password`\nfield through the entity surface (`PATCH /rest/users/id/:id`, `/admin/user/:id`) runs the same\nhashing hook, but the base `users` entity's `update` rule requires the `admin` role and there is no\nrow-level self-update rule — a signed-in user cannot rewrite their own password that way. A user who\nwants a new password goes through the reset flow below.\n\n## What a login does, in order\n\n1. Decode the body; `identity` and `password` must both be present.\n2. `realm: \"superadmin\"` short-circuits to the control plane — see\n   [superadmin](/blocks/baas/iam/authz/superadmin/).\n3. Resolve the tenant from the CEPT headers.\n4. Resolve the realm from the composed behavior config.\n5. Check that the realm allows the provider name `password`.\n6. Resolve the realm entity's declared identifier columns, narrowed by `identity_type`.\n7. Fire `before_login` (may rewrite `identity`), then `check_login_abuse`.\n8. Resolve the tenant's datastore engine.\n9. Query the realm entity once per candidate identifier column, `limit 1`, reading unmasked so the\n   at-rest hash is available.\n10. Verify the password with argon2id — or spend an equivalent budget on a sentinel hash.\n11. MFA gate: if the user has TOTP enrolled, return `mfa_required` and stop.\n12. Managed-session fork: on a managed tenant, register a session and return the reference and stop.\n13. Mint the access token, issue a refresh token, fire `after_login_success`, respond.\n\nThe rate limiter runs ahead of step 1, as a route wrapper.\n\n## Login request\n\n```http\nPOST /auth/login\nContent-Type: application/json\nX-Customer: acme\nX-Product: erp\nX-Env: prod\nX-Tenant: main\n```\n\n```json\n{\n  \"identity\": \"user@acme.io\",\n  \"password\": \"…\",\n  \"identity_type\": \"mobile\",\n  \"realm\": \"patient\"\n}\n```\n\n| Field | Type | Required | Meaning |\n|---|---|---|---|\n| `identity` | string | yes | Value matched against the realm entity's declared identifier columns |\n| `password` | string | yes | Plaintext; verified against the stored argon2id hash |\n| `identity_type` | string | no | Narrows the lookup to one declared identifier column |\n| `realm` | string | no | Names the authentication realm; empty uses the CEPT's default realm |\n\n### Identifier resolution\n\nWhich columns can identify a user is declared on the field, not on the login route. With\n`identity_type` omitted, the `useforidentity` column is tried first, then the remaining `useforauth`\ncolumns in declared order, one query each until a row matches. The base `users` entity declares\n`email` (both attributes) and `mobile` (`useforauth`). An entity that declares neither attribute\nfalls back to `[\"email\"]`.\n\nNaming a column that is not declared `useforauth` returns `400 bad_identity_type`, and the message\nlists the columns that *are* declared. The handler never filters on an arbitrary column name. Full\ndeclaration syntax is on [identity entities](/blocks/baas/iam/identity/entities/).\n\n:::note\nThe lookup is an exact equality filter. The service applies no normalisation — no lowercasing, no\ntrimming — so `Alice@Example.com` and `alice@example.com` match the same row only if your database\ncollation makes them equal. Normalising the identity is what a `before_login` hook is for.\n:::\n\n### The provider gate\n\nBefore any lookup, the resolved realm must allow the provider name `password`:\n\n```yaml\n# iam/realms.yaml\nrealms:\n  users:\n    default: true\n    active: true\n    providers: [password, magiclink]\n  patient:\n    active: true\n    entity: patient\n    providers: [password]\n```\n\nAn empty or absent `providers:` list means password-only. A realm that omits `password` refuses\npassword login with `403 provider_not_allowed`. An unknown realm and an inactive realm both return\n`400 unknown_realm`; the message distinguishes them. Realm composition and the entity binding are on\n[realms](/blocks/baas/iam/authn/realms/).\n\n:::caution\nThis gate is consulted by password login and nothing else. Magic link, WebAuthn and OAuth never call\nit — they hard-code the default realm and query the base `users` entity by email. A realm whose list\nis `[password]` still permits magic-link login for any user in `users`. Listing or omitting a\nnon-password provider name changes no behaviour at all.\n:::\n\n### What login does not check\n\n:::caution\nThe password path reads no account-state flag. The `users` entity declares `active` (required,\ndefault `true`) and `locked` (default `false`), and the block ships a whole `user_lock` entity — the\nlogin handler reads none of them. Setting `active: false`, setting `locked: true`, or writing a\n`user_lock` row does **not** stop that user logging in. Soft-deleting the row does: soft-deleted rows\nare excluded from the query. Control-plane operator login and impersonation do filter on\n`active: true`; tenant password login does not.\n:::\n\nThere is also no account lockout. Repeated failures never disable an account and never lengthen a\ndelay. The only brute-force controls are the built-in rate limiter below and the `check_login_abuse`\nhook.\n\n## Password storage and verification\n\nAny field named exactly `password` (case-sensitive) on any entity in the composed schema is\nargon2id-hashed on create and update by a boot-installed hook. That is what makes a product's own\nrealm entity work: name the column `password` and it is hashed on write with no further declaration.\nThe hook is idempotent on values already carrying the `$argon2` prefix, so round-tripping an existing\nhash through an update does not double-hash it, and an empty value is left alone.\n\nOn the base `users` entity the field also carries the `hash` and `redact` compliance controls, so the\nstored hash is redacted on every normal read — including `/rest` and `/admin/user`. Login is the\nexception: it reads with unmasking, which the entity's `unmask` rule grants only to the internal\nservice identity.\n\n| Parameter | Value |\n|---|---|\n| Algorithm | argon2id (RFC 9106 first-recommended profile) |\n| Iterations (`t`) | 1 |\n| Memory (`m`) | 2 GiB |\n| Parallelism (`p`) | 4 |\n| Salt | 128-bit, per hash |\n| Tag | 256-bit |\n\n:::caution\nThose parameters are compiled in. There is no config key, env var or YAML field that tunes them, and\nevery verification — including every *failed* one — pays the full memory cost. Size instances for\npeak concurrent logins, not for average request volume.\n:::\n\nVerification runs against the encoded hash. Three distinct failures return exactly the same\n`401 invalid_credentials`:\n\n- no row matches the identity,\n- the row exists but has an empty `password` (SSO-only, or a just-in-time federated user whose\n  password is unusable random bytes),\n- the password is wrong.\n\nThe first two paths verify a precomputed sentinel hash before answering, so they burn the same\nargon2 budget as a real verify and response timing does not leak whether the identity exists.\n\n### Password policy\n\nThere is none. No length, complexity, history or reuse rule is applied to a password anywhere in the\nblock.\n\n:::caution\nField validations cannot supply one. The hashing hook runs at priority 150 and the validation hook at\n200, so any `validations:` you declare on a `password` field are evaluated against the encoded\nargon2id string, not against what the user typed — a `length: {min: 12}` rule passes for every input.\nThe only place the plaintext is visible to a gate is the `before_password_reset_confirm` event, and\nthat covers the reset path alone: an admin creating a user through `/admin/user` passes no strength\ncheck at all.\n:::\n\n## Responses\n\n`POST /auth/login` returns `200` in three different shapes. Branch on the body, not the status.\n\n### Stateless tenant (the `standard` preset)\n\n```json\n{\n  \"token\": \"v4.public.…\",\n  \"refresh_token\": \"01J…ULID.<base64url-secret>\",\n  \"expires_in\": 900,\n  \"user\": { \"id\": \"U01K…\", \"email\": \"user@acme.io\", \"displayname\": \"…\" }\n}\n```\n\n`expires_in` is the access-token lifetime in seconds — 15 minutes unless the product's `token.yaml`\nsets `token.expiry`. The refresh token lives 30 days. `displayname` is a computed field\n(`firstname + \" \" + lastname`). Token format, claims and rotation are on\n[tokens](/blocks/baas/iam/tokens/).\n\n### MFA enrolled\n\n```json\n{ \"mfa_required\": true, \"mfa_token\": \"v4.public.…\", \"methods\": [\"totp\"] }\n```\n\nNo access or refresh token is issued. The client posts `mfa_token` plus a code to `/auth/mfa/verify`.\nThe `mfa_token` lives 5 minutes, carries the realm so the second step reads the same entity, and is\nrefused on every protected route. `methods` is always `[\"totp\"]` — see [MFA](/blocks/baas/iam/authn/mfa/).\n\n### Managed-session tenant (the `hipaa` and `fedramp` presets)\n\n```json\n{\n  \"session_id\": \"…\",\n  \"expires_at\": \"2026-07-27T18:00:00Z\",\n  \"user\": { \"id\": \"U01K…\", \"email\": \"user@acme.io\", \"displayname\": \"…\" }\n}\n```\n\nPlus `Set-Cookie: kis_session=…` with `HttpOnly`, `Secure` and `SameSite=Lax`. There is no refresh\ntoken on a managed tenant; `POST /auth/refresh` answers `403 managed_session`. The session records\nAAL 1 and auth method `password`. See [sessions](/blocks/baas/iam/tokens/sessions/).\n\n## Rate limiting\n\n`POST /auth/login` is bucketed per (tenant, caller IP) with a token bucket of capacity 30 and a refill\nof 0.5 per second. Both reset routes share the `auth_reset` bucket: capacity 15, refill 0.25 per\nsecond. Caller IP is taken from `X-Forwarded-For` (first value), then `X-Real-IP`, then the remote\naddress minus its port.\n\nA denial answers `429` with a different envelope from every other error on this surface:\n\n```json\n{ \"error\": \"rate_limited\", \"profile\": \"auth_login\", \"retry_after_seconds\": 12 }\n```\n\nwith `Retry-After`, `X-RateLimit-Limit` and `X-RateLimit-Remaining` headers.\n\n:::caution\nThe limiter is in-memory and per instance, so the effective fleet limit is the profile multiplied by\nthe replica count. Requests that resolve no tenant are not bucketed at all, and a limiter error other\nthan a denial allows the request through.\n:::\n\n## Event hooks\n\nLogin and reset emit eight events between them. `Before*` and `Check*` hooks abort the request;\n`After*` hooks observe.\n\n| Event | Kind | Abort response |\n|---|---|---|\n| `before_login` | gate; may rewrite `identity` | `403 hook_denied` |\n| `check_login_abuse` | gate | `429 login_abuse` |\n| `after_login_success` | observer; may decorate the payload | — |\n| `after_login_failure` | observer; carries `reason` | — |\n| `before_password_reset_request` | gate | `200 {\"ok\": true}` |\n| `after_password_reset_request` | observer; carries `sent`, `sink_error` | — |\n| `before_password_reset_confirm` | gate; sees `new_password` | `403 hook_denied` |\n| `after_password_reset_confirm` | observer | — |\n\n`after_login_failure` carries a `reason` of `user_not_found`, `no_password_set` or\n`invalid_credentials` — the distinction the API deliberately hides from the caller is available to\nyour observers.\n\n:::note\nA `before_password_reset_request` abort returns `200`, not a 4xx, so a hook cannot become an\nemail-enumeration oracle. A hook that needs to signal rejection must set a payload key the caller\nknows to read.\n:::\n\nScripted hooks load only from the service binary's embedded pointcut file, which ships with an empty\n`events:` list. A product's `iam/` folder carries no `events:` surface, so you cannot ship a\n`before_login` hook from a product today.\n\n## Password reset\n\nTwo calls. Both are hard-coded to the base `users` entity and the `email` column — the reset flow is\nrealm-unaware, so an identity that lives in a product realm entity cannot reset through it.\n\n### Request\n\n```http\nPOST /auth/password/reset/request\n```\n\n```json\n{ \"email\": \"user@acme.io\" }\n```\n\nReturns `200 {\"ok\": true}` unconditionally — for an unregistered email, and for a malformed body.\nWhen the email matches a row, the service generates a code, stores `hex(SHA-256(code))` in\n`password_reset_request` with an expiry 30 minutes out, and hands the plaintext to the notify sink\nwith purpose `password_reset` and channel `email`. The sink payload carries the user's id, email,\nfirst, last and display names, and `expires_in_minutes`, so a template can address the user by name.\n\n:::caution\nReset codes are 32 random bytes base64url-encoded — roughly 43 characters, not a 6-digit PIN. Size\nyour email template accordingly.\n:::\n\n:::caution\nWith `notify.url` unset the sink is a no-op: the code is generated, hashed and persisted, the endpoint\nstill returns `200`, and nothing is delivered. The only signal is the `sent` and `sink_error` keys on\n`after_password_reset_request`. Verify delivery from the event stream, not from the response. Wiring\nis on [operating](/blocks/baas/iam/operating/configuration/).\n:::\n\n### Confirm\n\n```http\nPOST /auth/password/reset/confirm\n```\n\n```json\n{ \"email\": \"user@acme.io\", \"code\": \"<code from the email>\", \"new_password\": \"…\" }\n```\n\nAll three fields are required. The row is matched on (email, code hash), then checked for expiry and\nprior use; any failure returns the single `401 invalid_reset_token`, with no indication of which check\nfailed. Expiry fails closed — an absent, zero or unparseable expiry counts as expired.\n\nOn success the new password is written to `users` filtered by email (the hashing hook re-hashes it),\nand the reset row is marked used. `email` carries a `unique` validation, so the filter resolves to one\nrow. Marking-used is best-effort; the password write is what matters.\n\n:::caution\nExisting credentials survive a reset. Nothing revokes the user's refresh-token families, API keys or\nmanaged sessions, so a reset alone does not evict an attacker who already holds a token. Revoke\nexplicitly — `POST /auth/logout` for a refresh family, `/internal/session/revoke` for a managed\nsession, `DELETE /auth/apikey/:id` for a key.\n:::\n\n`after_password_reset_confirm` reports an empty `user_id`: the reset row carries no user id column, so\nthe event's subject is always blank.\n\n## The `providers.yaml` declaration\n\nA product declares provider names in `iam/providers.yaml`. A provider has exactly three fields:\n\n| Field | Type | Required | Meaning |\n|---|---|---|---|\n| `name` | string | yes | The name a realm's `providers:` list references. An empty name is skipped silently. |\n| `type` | string | no | Free-form classification. Parsed and stored; never read at request time. |\n| `template` | string | no | Notification template name. Parsed and stored; never read at request time. |\n\n```yaml\n# iam/providers.yaml\nproviders:\n  - name: password\n    type: challenge\n  - name: o365\n    type: oauth\n  - name: magiclink\n    type: magic\n    template: magictemplate\n```\n\nThe built-in default is a single provider, `{name: password, type: challenge}`.\n\n:::caution\n`providers.yaml` carries no credentials. There is no client id, client secret, endpoint, callback URL\nor template body in the schema, and no handler reads `type` or `template`. Declaring `type: oauth`\nconfigures no identity provider — it creates a name a realm can list, nothing more.\n`template: magictemplate` is ignored; the magic-link handler hard-codes its purpose. The file is a\nname gate, and the only name ever gated is `password`.\n:::\n\nThere is no discovery endpoint. No route lists realms or providers, so a client must know its realm\nname out of band.\n\n## Error codes\n\nEvery error on this surface is a flat `{\"error\": \"<message>\", \"code\": \"<slug>\"}` body, except the\n`429 rate_limited` shape above. Codes are not unique across the block — key on the (status, code)\npair. The full catalogue is on [error](/blocks/baas/iam/error/).\n\n### `POST /auth/login`\n\n| Status | Code | Cause |\n|---|---|---|\n| 400 | `bad_request` | Body is not valid JSON |\n| 400 | `missing_fields` | `identity` or `password` absent |\n| 400 | `no_tenant` | Tenancy headers missing |\n| 400 | `unknown_realm` | Named realm does not exist, or is inactive |\n| 400 | `bad_identity_type` | `identity_type` names an undeclared column |\n| 401 | `invalid_credentials` | Unknown identity, wrong password, or no password set |\n| 403 | `provider_not_allowed` | The realm's `providers:` list omits `password` |\n| 403 | `hook_denied` | A `before_login` hook aborted |\n| 409 | `session_limit` | `max_concurrent_sessions` reached under `on_limit: reject` |\n| 429 | `login_abuse` | A `check_login_abuse` hook denied the attempt |\n| 429 | `rate_limited` | The `auth_login` bucket is empty |\n| 500 | `engine_unavailable` | The tenant datastore could not be resolved |\n| 500 | `user_query_failed` | Identity lookup failed at the engine |\n| 500 | `mfa_lookup_failed` | The MFA enrolment row could not be read |\n| 500 | `sign_failed` | Token signing failed (bad key, unreachable vault, missing keyring table) |\n| 500 | `issue_failed` | The refresh token could not be persisted |\n| 500 | `session_failed` | The managed session row could not be created |\n| 503 | `policy_unavailable` | The tenant's session policy could not be resolved; the service refuses rather than guessing stateless |\n\n### `POST /auth/password/reset/request`\n\n| Status | Code | Cause |\n|---|---|---|\n| 200 | — | Every normal outcome, including unregistered email, malformed body, and a hook abort |\n| 400 | `no_tenant` | Tenancy headers missing |\n| 429 | `rate_limited` | The `auth_reset` bucket is empty |\n| 500 | `engine_unavailable` | The tenant datastore could not be resolved |\n| 500 | `code_failed` | The one-time code could not be generated |\n| 500 | `persist_failed` | The reset row could not be written |\n\n### `POST /auth/password/reset/confirm`\n\n| Status | Code | Cause |\n|---|---|---|\n| 400 | `bad_request` | Body is not valid JSON |\n| 400 | `missing_fields` | `email`, `code` or `new_password` absent |\n| 400 | `no_tenant` | Tenancy headers missing |\n| 401 | `invalid_reset_token` | No matching row, expired, or already used |\n| 403 | `hook_denied` | A `before_password_reset_confirm` hook aborted |\n| 429 | `rate_limited` | The `auth_reset` bucket is empty |\n| 500 | `engine_unavailable` | The tenant datastore could not be resolved |\n| 500 | `update_failed` | The new password could not be written |\n\n## Worked example\n\n```bash\ncurl -s localhost:5030/auth/login \\\n  -H 'Content-Type: application/json' \\\n  -H 'X-Customer: default' -H 'X-Product: iam' -H 'X-Env: dev' -H 'X-Tenant: default' \\\n  -d '{\"identity\":\"admin@example.io\",\"password\":\"correct-horse-battery-staple\"}'\n```\n\n```json\n{\n  \"token\": \"v4.public.…\",\n  \"refresh_token\": \"01J…ULID.…\",\n  \"expires_in\": 900,\n  \"user\": { \"id\": \"U01K…\", \"email\": \"admin@example.io\", \"displayname\": \"Admin User\" }\n}\n```\n\n## Continue with\n\n- [Realms](/blocks/baas/iam/authn/realms/) — realm resolution, the entity binding, and composition layers\n- [MFA](/blocks/baas/iam/authn/mfa/) — TOTP enrolment and the second step of a login\n- [Passwordless](/blocks/baas/iam/authn/passwordless/) — magic link and the passkey routes\n- [Identity entities](/blocks/baas/iam/identity/entities/) — declaring identifier columns and your own realm entity\n- [Tokens](/blocks/baas/iam/tokens/) — claim set, lifetimes, refresh rotation, signing keys\n- [Endpoint reference](/blocks/baas/iam/api/) — every route, verbatim"
}