Skip to content
Talk to our solutions team

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.

MethodPathAuthRate-limit profile
POST/auth/loginpublicauth_login
POST/auth/password/reset/requestpublicauth_reset
POST/auth/password/reset/confirmpublic (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.

  1. Decode the body; identity and password must both be present.
  2. realm: "superadmin" short-circuits to the control plane — see superadmin.
  3. Resolve the tenant from the CPET 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.

POST /auth/login
Content-Type: application/json
X-Customer: acme
X-Product: erp
X-Env: prod
X-Tenant: main
{
"identity": "[email protected]",
"password": "",
"identity_type": "mobile",
"realm": "patient"
}
FieldTypeRequiredMeaning
identitystringyesValue matched against the realm entity’s declared identifier columns
passwordstringyesPlaintext; verified against the stored argon2id hash
identity_typestringnoNarrows the lookup to one declared identifier column
realmstringnoNames the authentication realm; empty uses the CPET’s default realm

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.

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

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.

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.

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.

ParameterValue
Algorithmargon2id (RFC 9106 first-recommended profile)
Iterations (t)1
Memory (m)2 GiB
Parallelism (p)4
Salt128-bit, per hash
Tag256-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.

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

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

{
"token": "v4.public.…",
"refresh_token": "01J…ULID.<base64url-secret>",
"expires_in": 900,
"user": { "id": "U01K…", "email": "[email protected]", "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.

{ "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",
"user": { "id": "U01K…", "email": "[email protected]", "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.

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.

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

EventKindAbort response
before_logingate; may rewrite identity403 hook_denied
check_login_abusegate429 login_abuse
after_login_successobserver; may decorate the payload
after_login_failureobserver; carries reason
before_password_reset_requestgate200 {"ok": true}
after_password_reset_requestobserver; carries sent, sink_error
before_password_reset_confirmgate; sees new_password403 hook_denied
after_password_reset_confirmobserver

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.

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.

POST /auth/password/reset/request
{ "email": "[email protected]" }

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.

POST /auth/password/reset/confirm
{ "email": "[email protected]", "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.

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.

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

FieldTypeRequiredMeaning
namestringyesThe name a realm’s providers: list references. An empty name is skipped silently.
typestringnoFree-form classification. Parsed and stored; never read at request time.
templatestringnoNotification template name. Parsed and stored; never read at request time.
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}.

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

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.

StatusCodeCause
400bad_requestBody is not valid JSON
400missing_fieldsidentity or password absent
400no_tenantTenancy headers missing
400unknown_realmNamed realm does not exist, or is inactive
400bad_identity_typeidentity_type names an undeclared column
401invalid_credentialsUnknown identity, wrong password, or no password set
403provider_not_allowedThe realm’s providers: list omits password
403hook_deniedA before_login hook aborted
409session_limitmax_concurrent_sessions reached under on_limit: reject
429login_abuseA check_login_abuse hook denied the attempt
429rate_limitedThe auth_login bucket is empty
500engine_unavailableThe tenant datastore could not be resolved
500user_query_failedIdentity lookup failed at the engine
500mfa_lookup_failedThe MFA enrolment row could not be read
500sign_failedToken signing failed (bad key, unreachable vault, missing keyring table)
500issue_failedThe refresh token could not be persisted
500session_service_unavailableTenant is managed but the session service is not wired
500session_failedThe managed session row could not be created
503policy_unavailableThe tenant’s session policy could not be resolved; the service refuses rather than guessing stateless
StatusCodeCause
200Every normal outcome, including unregistered email, malformed body, and a hook abort
400no_tenantTenancy headers missing
429rate_limitedThe auth_reset bucket is empty
500engine_unavailableThe tenant datastore could not be resolved
500code_failedThe one-time code could not be generated
500persist_failedThe reset row could not be written
StatusCodeCause
400bad_requestBody is not valid JSON
400missing_fieldsemail, code or new_password absent
400no_tenantTenancy headers missing
401invalid_reset_tokenNo matching row, expired, or already used
403hook_deniedA before_password_reset_confirm hook aborted
429rate_limitedThe auth_reset bucket is empty
500engine_unavailableThe tenant datastore could not be resolved
500update_failedThe new password could not be written
Terminal window
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":"[email protected]","password":"correct-horse-battery-staple"}'
{
"token": "v4.public.…",
"refresh_token": "01J…ULID.…",
"expires_in": 900,
"user": { "id": "U01K…", "email": "[email protected]", "displayname": "Admin User" }
}
  • 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