# Password login

> 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.

<!-- source: https://docs.kis.ai/blocks/baas/iam/authn/password/ -->

Password is the only credential flow in `iam.svc` that is realm-aware: it resolves a realm, checks
that realm's provider gate, looks the identity up in that realm's entity, and verifies an argon2id
hash. Every other login flow authenticates against the base `users` entity in the default realm.

## Routes

| Method | Path | Auth | Rate-limit profile |
|---|---|---|---|
| `POST` | `/auth/login` | public | `auth_login` |
| `POST` | `/auth/password/reset/request` | public | `auth_reset` |
| `POST` | `/auth/password/reset/confirm` | public (the code is the credential) | `auth_reset` |

All three require the four tenancy headers — `X-Customer`, `X-Product`, `X-Env`, `X-Tenant`. Without
them the request fails with `400 no_tenant` before any credential is read. There is no `/v2` prefix
on any route.

There is no self-service change-password endpoint and no self-service signup. Writing the `password`
field through the entity surface (`PATCH /rest/users/id/:id`, `/admin/user/:id`) runs the same
hashing hook, but the base `users` entity's `update` rule requires the `admin` role and there is no
row-level self-update rule — a signed-in user cannot rewrite their own password that way. A user who
wants a new password goes through the reset flow below.

## What a login does, in order

1. Decode the body; `identity` and `password` must both be present.
2. `realm: "superadmin"` short-circuits to the control plane — see
   [superadmin](/blocks/baas/iam/authz/superadmin/).
3. Resolve the tenant from the CEPT headers.
4. Resolve the realm from the composed behavior config.
5. Check that the realm allows the provider name `password`.
6. Resolve the realm entity's declared identifier columns, narrowed by `identity_type`.
7. Fire `before_login` (may rewrite `identity`), then `check_login_abuse`.
8. Resolve the tenant's datastore engine.
9. Query the realm entity once per candidate identifier column, `limit 1`, reading unmasked so the
   at-rest hash is available.
10. Verify the password with argon2id — or spend an equivalent budget on a sentinel hash.
11. MFA gate: if the user has TOTP enrolled, return `mfa_required` and stop.
12. Managed-session fork: on a managed tenant, register a session and return the reference and stop.
13. Mint the access token, issue a refresh token, fire `after_login_success`, respond.

The rate limiter runs ahead of step 1, as a route wrapper.

## Login request

```http
POST /auth/login
Content-Type: application/json
X-Customer: acme
X-Product: erp
X-Env: prod
X-Tenant: main
```

```json
{
  "identity": "user@acme.io",
  "password": "…",
  "identity_type": "mobile",
  "realm": "patient"
}
```

| Field | Type | Required | Meaning |
|---|---|---|---|
| `identity` | string | yes | Value matched against the realm entity's declared identifier columns |
| `password` | string | yes | Plaintext; verified against the stored argon2id hash |
| `identity_type` | string | no | Narrows the lookup to one declared identifier column |
| `realm` | string | no | Names the authentication realm; empty uses the CEPT's default realm |

### Identifier resolution

Which columns can identify a user is declared on the field, not on the login route. With
`identity_type` omitted, the `useforidentity` column is tried first, then the remaining `useforauth`
columns in declared order, one query each until a row matches. The base `users` entity declares
`email` (both attributes) and `mobile` (`useforauth`). An entity that declares neither attribute
falls back to `["email"]`.

Naming a column that is not declared `useforauth` returns `400 bad_identity_type`, and the message
lists the columns that *are* declared. The handler never filters on an arbitrary column name. Full
declaration syntax is on [identity entities](/blocks/baas/iam/identity/entities/).

:::note
The lookup is an exact equality filter. The service applies no normalisation — no lowercasing, no
trimming — so `Alice@Example.com` and `alice@example.com` match the same row only if your database
collation makes them equal. Normalising the identity is what a `before_login` hook is for.
:::

### The provider gate

Before any lookup, the resolved realm must allow the provider name `password`:

```yaml
# iam/realms.yaml
realms:
  users:
    default: true
    active: true
    providers: [password, magiclink]
  patient:
    active: true
    entity: patient
    providers: [password]
```

An empty or absent `providers:` list means password-only. A realm that omits `password` refuses
password login with `403 provider_not_allowed`. An unknown realm and an inactive realm both return
`400 unknown_realm`; the message distinguishes them. Realm composition and the entity binding are on
[realms](/blocks/baas/iam/authn/realms/).

:::caution
This gate is consulted by password login and nothing else. Magic link, WebAuthn and OAuth never call
it — they hard-code the default realm and query the base `users` entity by email. A realm whose list
is `[password]` still permits magic-link login for any user in `users`. Listing or omitting a
non-password provider name changes no behaviour at all.
:::

### What login does not check

:::caution
The password path reads no account-state flag. The `users` entity declares `active` (required,
default `true`) and `locked` (default `false`), and the block ships a whole `user_lock` entity — the
login handler reads none of them. Setting `active: false`, setting `locked: true`, or writing a
`user_lock` row does **not** stop that user logging in. Soft-deleting the row does: soft-deleted rows
are excluded from the query. Control-plane operator login and impersonation do filter on
`active: true`; tenant password login does not.
:::

There is also no account lockout. Repeated failures never disable an account and never lengthen a
delay. The only brute-force controls are the built-in rate limiter below and the `check_login_abuse`
hook.

## Password storage and verification

Any field named exactly `password` (case-sensitive) on any entity in the composed schema is
argon2id-hashed on create and update by a boot-installed hook. That is what makes a product's own
realm entity work: name the column `password` and it is hashed on write with no further declaration.
The hook is idempotent on values already carrying the `$argon2` prefix, so round-tripping an existing
hash through an update does not double-hash it, and an empty value is left alone.

On the base `users` entity the field also carries the `hash` and `redact` compliance controls, so the
stored hash is redacted on every normal read — including `/rest` and `/admin/user`. Login is the
exception: it reads with unmasking, which the entity's `unmask` rule grants only to the internal
service identity.

| Parameter | Value |
|---|---|
| Algorithm | argon2id (RFC 9106 first-recommended profile) |
| Iterations (`t`) | 1 |
| Memory (`m`) | 2 GiB |
| Parallelism (`p`) | 4 |
| Salt | 128-bit, per hash |
| Tag | 256-bit |

:::caution
Those parameters are compiled in. There is no config key, env var or YAML field that tunes them, and
every verification — including every *failed* one — pays the full memory cost. Size instances for
peak concurrent logins, not for average request volume.
:::

Verification runs against the encoded hash. Three distinct failures return exactly the same
`401 invalid_credentials`:

- no row matches the identity,
- the row exists but has an empty `password` (SSO-only, or a just-in-time federated user whose
  password is unusable random bytes),
- the password is wrong.

The first two paths verify a precomputed sentinel hash before answering, so they burn the same
argon2 budget as a real verify and response timing does not leak whether the identity exists.

### Password policy

There is none. No length, complexity, history or reuse rule is applied to a password anywhere in the
block.

:::caution
Field validations cannot supply one. The hashing hook runs at priority 150 and the validation hook at
200, so any `validations:` you declare on a `password` field are evaluated against the encoded
argon2id string, not against what the user typed — a `length: {min: 12}` rule passes for every input.
The only place the plaintext is visible to a gate is the `before_password_reset_confirm` event, and
that covers the reset path alone: an admin creating a user through `/admin/user` passes no strength
check at all.
:::

## Responses

`POST /auth/login` returns `200` in three different shapes. Branch on the body, not the status.

### Stateless tenant (the `standard` preset)

```json
{
  "token": "v4.public.…",
  "refresh_token": "01J…ULID.<base64url-secret>",
  "expires_in": 900,
  "user": { "id": "U01K…", "email": "user@acme.io", "displayname": "…" }
}
```

`expires_in` is the access-token lifetime in seconds — 15 minutes unless the product's `token.yaml`
sets `token.expiry`. The refresh token lives 30 days. `displayname` is a computed field
(`firstname + " " + lastname`). Token format, claims and rotation are on
[tokens](/blocks/baas/iam/tokens/).

### MFA enrolled

```json
{ "mfa_required": true, "mfa_token": "v4.public.…", "methods": ["totp"] }
```

No access or refresh token is issued. The client posts `mfa_token` plus a code to `/auth/mfa/verify`.
The `mfa_token` lives 5 minutes, carries the realm so the second step reads the same entity, and is
refused on every protected route. `methods` is always `["totp"]` — see [MFA](/blocks/baas/iam/authn/mfa/).

### Managed-session tenant (the `hipaa` and `fedramp` presets)

```json
{
  "session_id": "…",
  "expires_at": "2026-07-27T18:00:00Z",
  "user": { "id": "U01K…", "email": "user@acme.io", "displayname": "…" }
}
```

Plus `Set-Cookie: kis_session=…` with `HttpOnly`, `Secure` and `SameSite=Lax`. There is no refresh
token on a managed tenant; `POST /auth/refresh` answers `403 managed_session`. The session records
AAL 1 and auth method `password`. See [sessions](/blocks/baas/iam/tokens/sessions/).

## Rate limiting

`POST /auth/login` is bucketed per (tenant, caller IP) with a token bucket of capacity 30 and a refill
of 0.5 per second. Both reset routes share the `auth_reset` bucket: capacity 15, refill 0.25 per
second. Caller IP is taken from `X-Forwarded-For` (first value), then `X-Real-IP`, then the remote
address minus its port.

A denial answers `429` with a different envelope from every other error on this surface:

```json
{ "error": "rate_limited", "profile": "auth_login", "retry_after_seconds": 12 }
```

with `Retry-After`, `X-RateLimit-Limit` and `X-RateLimit-Remaining` headers.

:::caution
The limiter is in-memory and per instance, so the effective fleet limit is the profile multiplied by
the replica count. Requests that resolve no tenant are not bucketed at all, and a limiter error other
than a denial allows the request through.
:::

## Event hooks

Login and reset emit eight events between them. `Before*` and `Check*` hooks abort the request;
`After*` hooks observe.

| Event | Kind | Abort response |
|---|---|---|
| `before_login` | gate; may rewrite `identity` | `403 hook_denied` |
| `check_login_abuse` | gate | `429 login_abuse` |
| `after_login_success` | observer; may decorate the payload | — |
| `after_login_failure` | observer; carries `reason` | — |
| `before_password_reset_request` | gate | `200 {"ok": true}` |
| `after_password_reset_request` | observer; carries `sent`, `sink_error` | — |
| `before_password_reset_confirm` | gate; sees `new_password` | `403 hook_denied` |
| `after_password_reset_confirm` | observer | — |

`after_login_failure` carries a `reason` of `user_not_found`, `no_password_set` or
`invalid_credentials` — the distinction the API deliberately hides from the caller is available to
your observers.

:::note
A `before_password_reset_request` abort returns `200`, not a 4xx, so a hook cannot become an
email-enumeration oracle. A hook that needs to signal rejection must set a payload key the caller
knows to read.
:::

Scripted hooks load only from the service binary's embedded pointcut file, which ships with an empty
`events:` list. A product's `iam/` folder carries no `events:` surface, so you cannot ship a
`before_login` hook from a product today.

## Password reset

Two calls. Both are hard-coded to the base `users` entity and the `email` column — the reset flow is
realm-unaware, so an identity that lives in a product realm entity cannot reset through it.

### Request

```http
POST /auth/password/reset/request
```

```json
{ "email": "user@acme.io" }
```

Returns `200 {"ok": true}` unconditionally — for an unregistered email, and for a malformed body.
When the email matches a row, the service generates a code, stores `hex(SHA-256(code))` in
`password_reset_request` with an expiry 30 minutes out, and hands the plaintext to the notify sink
with purpose `password_reset` and channel `email`. The sink payload carries the user's id, email,
first, last and display names, and `expires_in_minutes`, so a template can address the user by name.

:::caution
Reset codes are 32 random bytes base64url-encoded — roughly 43 characters, not a 6-digit PIN. Size
your email template accordingly.
:::

:::caution
With `notify.url` unset the sink is a no-op: the code is generated, hashed and persisted, the endpoint
still returns `200`, and nothing is delivered. The only signal is the `sent` and `sink_error` keys on
`after_password_reset_request`. Verify delivery from the event stream, not from the response. Wiring
is on [operating](/blocks/baas/iam/operating/configuration/).
:::

### Confirm

```http
POST /auth/password/reset/confirm
```

```json
{ "email": "user@acme.io", "code": "<code from the email>", "new_password": "…" }
```

All three fields are required. The row is matched on (email, code hash), then checked for expiry and
prior use; any failure returns the single `401 invalid_reset_token`, with no indication of which check
failed. Expiry fails closed — an absent, zero or unparseable expiry counts as expired.

On success the new password is written to `users` filtered by email (the hashing hook re-hashes it),
and the reset row is marked used. `email` carries a `unique` validation, so the filter resolves to one
row. Marking-used is best-effort; the password write is what matters.

:::caution
Existing credentials survive a reset. Nothing revokes the user's refresh-token families, API keys or
managed sessions, so a reset alone does not evict an attacker who already holds a token. Revoke
explicitly — `POST /auth/logout` for a refresh family, `/internal/session/revoke` for a managed
session, `DELETE /auth/apikey/:id` for a key.
:::

`after_password_reset_confirm` reports an empty `user_id`: the reset row carries no user id column, so
the event's subject is always blank.

## The `providers.yaml` declaration

A product declares provider names in `iam/providers.yaml`. A provider has exactly three fields:

| Field | Type | Required | Meaning |
|---|---|---|---|
| `name` | string | yes | The name a realm's `providers:` list references. An empty name is skipped silently. |
| `type` | string | no | Free-form classification. Parsed and stored; never read at request time. |
| `template` | string | no | Notification template name. Parsed and stored; never read at request time. |

```yaml
# iam/providers.yaml
providers:
  - name: password
    type: challenge
  - name: o365
    type: oauth
  - name: magiclink
    type: magic
    template: magictemplate
```

The built-in default is a single provider, `{name: password, type: challenge}`.

:::caution
`providers.yaml` carries no credentials. There is no client id, client secret, endpoint, callback URL
or template body in the schema, and no handler reads `type` or `template`. Declaring `type: oauth`
configures no identity provider — it creates a name a realm can list, nothing more.
`template: magictemplate` is ignored; the magic-link handler hard-codes its purpose. The file is a
name gate, and the only name ever gated is `password`.
:::

There is no discovery endpoint. No route lists realms or providers, so a client must know its realm
name out of band.

## Error codes

Every error on this surface is a flat `{"error": "<message>", "code": "<slug>"}` body, except the
`429 rate_limited` shape above. Codes are not unique across the block — key on the (status, code)
pair. The full catalogue is on [error](/blocks/baas/iam/error/).

### `POST /auth/login`

| Status | Code | Cause |
|---|---|---|
| 400 | `bad_request` | Body is not valid JSON |
| 400 | `missing_fields` | `identity` or `password` absent |
| 400 | `no_tenant` | Tenancy headers missing |
| 400 | `unknown_realm` | Named realm does not exist, or is inactive |
| 400 | `bad_identity_type` | `identity_type` names an undeclared column |
| 401 | `invalid_credentials` | Unknown identity, wrong password, or no password set |
| 403 | `provider_not_allowed` | The realm's `providers:` list omits `password` |
| 403 | `hook_denied` | A `before_login` hook aborted |
| 409 | `session_limit` | `max_concurrent_sessions` reached under `on_limit: reject` |
| 429 | `login_abuse` | A `check_login_abuse` hook denied the attempt |
| 429 | `rate_limited` | The `auth_login` bucket is empty |
| 500 | `engine_unavailable` | The tenant datastore could not be resolved |
| 500 | `user_query_failed` | Identity lookup failed at the engine |
| 500 | `mfa_lookup_failed` | The MFA enrolment row could not be read |
| 500 | `sign_failed` | Token signing failed (bad key, unreachable vault, missing keyring table) |
| 500 | `issue_failed` | The refresh token could not be persisted |
| 500 | `session_failed` | The managed session row could not be created |
| 503 | `policy_unavailable` | The tenant's session policy could not be resolved; the service refuses rather than guessing stateless |

### `POST /auth/password/reset/request`

| Status | Code | Cause |
|---|---|---|
| 200 | — | Every normal outcome, including unregistered email, malformed body, and a hook abort |
| 400 | `no_tenant` | Tenancy headers missing |
| 429 | `rate_limited` | The `auth_reset` bucket is empty |
| 500 | `engine_unavailable` | The tenant datastore could not be resolved |
| 500 | `code_failed` | The one-time code could not be generated |
| 500 | `persist_failed` | The reset row could not be written |

### `POST /auth/password/reset/confirm`

| Status | Code | Cause |
|---|---|---|
| 400 | `bad_request` | Body is not valid JSON |
| 400 | `missing_fields` | `email`, `code` or `new_password` absent |
| 400 | `no_tenant` | Tenancy headers missing |
| 401 | `invalid_reset_token` | No matching row, expired, or already used |
| 403 | `hook_denied` | A `before_password_reset_confirm` hook aborted |
| 429 | `rate_limited` | The `auth_reset` bucket is empty |
| 500 | `engine_unavailable` | The tenant datastore could not be resolved |
| 500 | `update_failed` | The new password could not be written |

## Worked example

```bash
curl -s localhost:5030/auth/login \
  -H 'Content-Type: application/json' \
  -H 'X-Customer: default' -H 'X-Product: iam' -H 'X-Env: dev' -H 'X-Tenant: default' \
  -d '{"identity":"admin@example.io","password":"correct-horse-battery-staple"}'
```

```json
{
  "token": "v4.public.…",
  "refresh_token": "01J…ULID.…",
  "expires_in": 900,
  "user": { "id": "U01K…", "email": "admin@example.io", "displayname": "Admin User" }
}
```

## Continue with

- [Realms](/blocks/baas/iam/authn/realms/) — realm resolution, the entity binding, and composition layers
- [MFA](/blocks/baas/iam/authn/mfa/) — TOTP enrolment and the second step of a login
- [Passwordless](/blocks/baas/iam/authn/passwordless/) — magic link and the passkey routes
- [Identity entities](/blocks/baas/iam/identity/entities/) — declaring identifier columns and your own realm entity
- [Tokens](/blocks/baas/iam/tokens/) — claim set, lifetimes, refresh rotation, signing keys
- [Endpoint reference](/blocks/baas/iam/api/) — every route, verbatim