Password login
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
Section titled “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
Section titled “What a login does, in order”- Decode the body;
identityandpasswordmust both be present. realm: "superadmin"short-circuits to the control plane — see superadmin.- Resolve the tenant from the CPET headers.
- Resolve the realm from the composed behavior config.
- Check that the realm allows the provider name
password. - Resolve the realm entity’s declared identifier columns, narrowed by
identity_type. - Fire
before_login(may rewriteidentity), thencheck_login_abuse. - Resolve the tenant’s datastore engine.
- Query the realm entity once per candidate identifier column,
limit 1, reading unmasked so the at-rest hash is available. - Verify the password with argon2id — or spend an equivalent budget on a sentinel hash.
- MFA gate: if the user has TOTP enrolled, return
mfa_requiredand stop. - Managed-session fork: on a managed tenant, register a session and return the reference and stop.
- 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
Section titled “Login request”POST /auth/loginContent-Type: application/jsonX-Customer: acmeX-Product: erpX-Env: prodX-Tenant: main{ "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 CPET’s default realm |
Identifier resolution
Section titled “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.
The provider gate
Section titled “The provider gate”Before any lookup, the resolved realm must allow the provider name password:
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.
What login does not check
Section titled “What login does not check”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
Section titled “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 |
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
Section titled “Password policy”There is none. No length, complexity, history or reuse rule is applied to a password anywhere in the block.
Responses
Section titled “Responses”POST /auth/login returns 200 in three different shapes. Branch on the body, not the status.
Stateless tenant (the standard preset)
Section titled “Stateless tenant (the standard preset)”{ "token": "v4.public.…", "refresh_token": "01J…ULID.<base64url-secret>", "expires_in": 900,}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.
MFA enrolled
Section titled “MFA enrolled”{ "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.
Managed-session tenant (the hipaa and fedramp presets)
Section titled “Managed-session tenant (the hipaa and fedramp presets)”{ "session_id": "…", "expires_at": "2026-07-27T18:00:00Z",}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.
Rate limiting
Section titled “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:
{ "error": "rate_limited", "profile": "auth_login", "retry_after_seconds": 12 }with Retry-After, X-RateLimit-Limit and X-RateLimit-Remaining headers.
Event hooks
Section titled “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.
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
Section titled “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
Section titled “Request”POST /auth/password/reset/requestReturns 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.
Confirm
Section titled “Confirm”POST /auth/password/reset/confirmAll 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.
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
Section titled “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. |
providers: - name: password type: challenge - name: o365 type: oauth - name: magiclink type: magic template: magictemplateThe built-in default is a single provider, {name: password, type: challenge}.
There is no discovery endpoint. No route lists realms or providers, so a client must know its realm name out of band.
Error codes
Section titled “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.
POST /auth/login
Section titled “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_service_unavailable | Tenant is managed but the session service is not wired |
| 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
Section titled “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
Section titled “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
Section titled “Worked example”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' \{ "token": "v4.public.…", "refresh_token": "01J…ULID.…", "expires_in": 900,}Continue with
Section titled “Continue with”- Realms — realm resolution, the entity binding, and composition layers
- MFA — TOTP enrolment and the second step of a login
- Passwordless — magic link and the passkey routes
- Identity entities — declaring identifier columns and your own realm entity
- Tokens — claim set, lifetimes, refresh rotation, signing keys
- Endpoint reference — every route, verbatim