Skip to content
Talk to our solutions team

Your first login

Every command on this page is runnable as written, and every response body is a real one captured from a local boot against PostgreSQL 18. The whole sequence needs one binary, one database and nothing else — no config server, no vault, no metadata service, no service registry.

RequirementDetail
iam.svcThe single binary. iam version prints the build.
A PostgreSQL databaseReachable, already created. iam.svc creates schemas, never databases.
curlOr anything that sets request headers.

The dev boot below uses erpdb on localhost:5432 with user erpadmin — change the dbpools block to match your own.

Five config profiles are embedded in the binary:

Terminal window
iam.svc config list
Config profiles (use `iam config generate <name>` to print, or `-d <dir>` to write):
bootstrap-config-server Boot via the kis.ai config server — tenants/datastores/dbpools come from baas-config, not inline.
bootstrap-dev Local single-file dev boot — embedded tenant hive, local Postgres, zero-trust off.
bootstrap-full Production target — config server + vault-backed creds + zero-trust mTLS + telemetry + service discovery.
bootstrap-managed-session Managed server-side sessions (hipaa preset) — local dev boot. Like
bootstrap-vault Vault-backed DB credentials + zero-trust mTLS — DB passwords are vault keys, not literals.
Reserved name 'all' writes every profile (requires -d).

Take the dev profile:

Terminal window
iam.svc config generate bootstrap-dev > .iam.yaml

It is a complete boot in one file. Because it sets neither config.url nor config.path, its own hives: block is the configuration source — the cluster node and the tenant nodes are read from the same file the process booted from.

The keys that decide whether this boot works:

KeyValue in the profileWhy it matters
port5030Required. Boot aborts with invalid/empty port provided if it is empty in both the flag and the file.
host127.0.0.1Empty binds every interface.
zerotrustfalseDefaults to true when the key is absent — and then the listener demands TLS material and fails to start.
jwks.issuers.iamhttp://127.0.0.1:5030Top-level, a sibling of host/port. This service verifies its own tokens through it.
hives.cluster[].path: ""carries bootstrap.tokenThe service’s own cluster node.
hives.tenant[]one node per planeEach node carries datastores and dbpools.

Make two edits before you run anything.

Address the control-plane node by its full CPET. The profile ships the reserved node as path: superadmin, which never binds: tenant nodes resolve by walking the prefixes of the key customer:product:env:tenant, and superadmin is not a prefix of default:iam:dev:superadmin. The control plane then silently shares the tenant’s own pool and schema.

hives:
tenant:
- path: default:iam:dev:superadmin # not `superadmin`
name: superadmin
active: true
datastores:
main:
dbpool: ctrlpool
dbpools:
ctrlpool:
type: postgres
dbserver: localhost:5432
database: erpdb
dbuser: erpadmin
password: erpadmin
schema: superadmin
maxconnections: 5
connect_timeout: 5

Set the log level in the file, not on the command line. -l/--loglevel is applied before the chassis reads log.level, whose default is error, so the config key always wins and the flag does nothing.

log:
level: info

The database must exist. The PostgreSQL schema is created for you.

Terminal window
iam.svc data bootstrap -f .iam.yaml
Plan U18C63293FF6571C00000000001 (source=yaml, max_risk=low)
Steps (61):
1. [ddl ] add_table data_applied_changes. CREATE TABLE IF NOT EXISTS "iam"."data_applied_changes...
57. [ddl ] add_table users. CREATE TABLE IF NOT EXISTS "iam"."users" ( "id" CHAR(2...
58. [ddl ] add_audit_table users.users_audit CREATE TABLE IF NOT EXISTS "iam"."users_audit" ( "id" ...
61. [ddl ] add_table webauthn_session. CREATE TABLE IF NOT EXISTS "iam"."webauthn_session" ( ...
plan_id: U18C6326CCBE386380000000001
status: applied
applied: 61 failed: 0 skipped: 0

The control plane is a separate schema with its own tables, and it migrates separately:

Terminal window
iam.svc data bootstrap -f .iam.yaml --tenant default:iam:dev:superadmin
plan_id: U18C6326CEEC7E9A00000000001
status: applied
applied: 34 failed: 0 skipped: 0

Both are idempotent — every step is IF NOT EXISTS, so re-running changes nothing. Add --dry-run to print the plan without applying it.

FactDetail
--tenantAlways four segments. --tenant superadmin is rejected: invalid --tenant "superadmin" (want customer:product:env:tenant).
Default --tenantdefault:iam:dev:default on data and seed.
DDL is never silentEvery statement is printed before it is applied.
One CPET per runThere is no all-tenants pass; roll a fleet with your own loop.
After bootstrapSteady-state changes use the persisted, risk-gated plan lifecycle (data plan generate / show / apply).

schema "…" does not exist after a successful bootstrap means the database is missing, not the schema.

There is no self-service signup route. Accounts come from admin CRUD, tenant provisioning, just-in-time creation on a federated login, or this command:

Terminal window
iam.svc seed -f .iam.yaml --email [email protected] --password 'Passw0rd!' --firstname Ada --lastname Lovelace
superadmin: created id=U01KYJ6HFJB5CJ975702J5HJ6WY [email protected]
admin user: created id=U01KYJ6HG0Q4RKHJCQMBKTZNYNQ [email protected] roles=[admin]

Two rows in two planes:

RowLands inRoles
operatorthe control plane default:iam:dev:superadminroot
tenant adminthe tenant’s users entityproperties.roles: ["admin"], override with repeatable --role

Both writes go through the per-tenant engine, so the argon2id password hook and the entity access rules run exactly as they would for an API write. Re-running is safe:

superadmin: already exists — skipped
admin user: already exists — skipped

Pass --superadmin=false to create only the tenant admin. Pointing --tenant at a control plane seeds the operator and skips the admin user — a plane has no users entity.

The HTTP alternative for the very first operator is POST /auth/bootstrap, gated by the cluster key bootstrap.token and permanently closed once any operator row exists. See Superadmin.

Terminal window
iam.svc -f .iam.yaml
iam service started at 127.0.0.1:5030

One listener, one port. There is no separate admin, metrics or pprof port.

Terminal window
curl -s http://127.0.0.1:5030/ready
ready:true
Terminal window
curl -s http://127.0.0.1:5030/health
{ "healthy":true,"dependencies": {}, "memstats": { "Alloc":2053, "HeapAlloc":2053, "HeapSys":4110, "HeapIdle":2054, "HeapInUse":2056, "TotalAlloc":10255, "Sys":4125, "NumGC":7}, "version": "" }

SO_REUSEPORT is on by default, so starting a second instance on the same address succeeds instead of failing, and requests split between the two. Check before you assume you restarted something.

Every route except /ready and /health needs the four tenant headers.

Terminal window
curl -s http://127.0.0.1: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":"Passw0rd!"}'
{
"token": "v4.public.eyJleHAiOjE3ODUxNzA2NzYsImlhdCI6MTc4NTE2OTc3NiwiaXNzIjoiaWFtIiwibmJmIjoxNzg1MTY5Nzc2LCJyZWFsbSI6InVzZXJzIiwicm9sZXMiOlsiYWRtaW4iXSwic3ViIjoiVTAxS1lKNkhHMFE0UktISkNRTUJLVFpOWU5RIiwidGVuYW50IjoiZGVmYXVsdDppYW06ZGV2OmRlZmF1bHQiLCJ0eXBlIjoidXNlciJ9aTno22fjHMo0FEp_4O2ZbgyDTlzwWcTOwzdgAHIJwCPYZYx8Dz4xFfZYqQasLfQeTo9dp6PPadNto9nr18r-DQ.eyJraWQiOiIwMUtZSjZKM0VHQTdaQzZaUUEwNTkzTkZGOSJ9",
"refresh_token": "01KYJ6J3EKHQ8ZDMKRSYT5FVFW.H-MlfkEFrDR9YOfEFpeVT3e2xuSO-gmHmA2YWIMXjKI",
"user": {
"id": "U01KYJ6HG0Q4RKHJCQMBKTZNYNQ",
"email": "[email protected]",
"displayname": null
},
"expires_in": 900
}

The access token is PASETO v4.public signed with the tenant’s own Ed25519 key. Its payload:

{"exp":1785170676,"iat":1785169776,"iss":"iam","nbf":1785169776,"realm":"users","roles":["admin"],"sub":"U01KYJ6HG0Q4RKHJCQMBKTZNYNQ","tenant":"default:iam:dev:default","type":"user"}
ClaimValue hereMeaning
issiamIssuer. Resource servers map it to a JWKS URL.
subU01K…The identity row id in the authenticating realm’s entity.
tenantdefault:iam:dev:defaultThe full CPET the token is valid for.
realmusersThe realm that authenticated.
roles["admin"]Read from the identity row’s properties.roles. Omitted when empty.
typeuserservice for exchanged API keys, mfa_pending for the intermediate MFA token.
exp / iat / nbfexpires_in is 900 seconds unless the tenant sets token.expiry.

No PII is in the claim set by design. The public keys are served per tenant:

Terminal window
curl -s http://127.0.0.1:5030/.well-known/jwks.json \
-H 'X-Customer: default' -H 'X-Product: iam' -H 'X-Env: dev' -H 'X-Tenant: default'
{"keys":[{"kid":"01KYJ6J3EGA7ZC6ZQA0593NFF9","kty":"OKP","crv":"Ed25519","x":"_EC6QkLRF6sgP8Y5V8GXIdOah16QKEwxuz7iAYB0Ml8","alg":"EdDSA","use":"sig"}]}

The signing key was generated into the tenant’s own keyring on this first use, because the dev profile sets no jwt.signing.key. Details in Tokens.

Keep the access token in a variable — the rest of this page uses $TOKEN:

Terminal window
TOKEN=$(curl -s http://127.0.0.1:5030/auth/login -H 'X-Customer: default' -H 'X-Product: iam' -H 'X-Env: dev' -H 'X-Tenant: default' -d '{"identity":"[email protected]","password":"Passw0rd!"}' | python3 -c 'import json,sys;print(json.load(sys.stdin)["token"])')
Terminal window
curl -s http://127.0.0.1:5030/rest/users \
-H "Authorization: Bearer $TOKEN" \
-H 'X-Customer: default' -H 'X-Product: iam' -H 'X-Env: dev' -H 'X-Tenant: default'
{
"data": [
{
"id": "U01KYJ6HG0Q4RKHJCQMBKTZNYNQ",
"email": "[email protected]",
"firstname": "Ada",
"lastname": "Lovelace",
"middlename": "",
"mobile": "+0000000000000",
"avatar": "",
"active": true,
"locked": false,
"allow_impersonation": false,
"password": "[REDACTED]",
"properties": { "roles": ["admin"] },
"createdby": "iam-service",
"createdon": "2026-07-27T16:29:16.542503Z",
"updatedby": "iam-service",
"updatedon": "2026-07-27T16:29:16.542503Z"
}
],
"count": 1
}

Three things that response proves, and that you should expect everywhere:

  • The password field is [REDACTED] on every read. Recovering the hash needs the unmask action, which only the service’s own internal principal holds.
  • createdby/createdon/updatedby/updatedon are injected on every entity, along with soft-delete columns. Nothing opts out.
  • The tenant admin sees every row because the users read filter returns unrestricted for admin. A user without that role sees exactly one row — their own. See Entities.

The sentinel mobile +0000000000000 is what the seeder writes when you pass no --mobile. It is unique-constrained, so only one seeded identity per tenant can hold it.

The headers must keep matching the token. Presenting this token with a different X-Tenant returns 401 {"code":"invalid_token","error":"token validation failed"} — the key set is per tenant.

The full endpoint list is at the IAM API reference.

Terminal window
curl -s http://127.0.0.1:5030/auth/refresh \
-H 'X-Customer: default' -H 'X-Product: iam' -H 'X-Env: dev' -H 'X-Tenant: default' \
-d '{"refresh_token":"01KYJ6J3EKHQ8ZDMKRSYT5FVFW.H-MlfkEFrDR9YOfEFpeVT3e2xuSO-gmHmA2YWIMXjKI"}'
{
"token": "v4.public.eyJleHAiOjE3ODUxNzA3MTks…",
"refresh_token": "01KYJ6KDXRRQBEYMEA9RNVTZGG.H2IPiD4ab6191qSm3mzroBDngDj3tO5NuYHKVGIBYlg",
"expires_in": 900
}

There is deliberately no user object on a refresh. The old refresh token is now a tombstone — presenting it again kills the whole rotation family:

{"code":"token_reuse","error":"refresh token reuse detected; session revoked"}

Logout revokes the presented token’s entire family, and is idempotent — an unknown or already-dead token still returns 200:

Terminal window
curl -s http://127.0.0.1:5030/auth/logout \
-H 'X-Customer: default' -H 'X-Product: iam' -H 'X-Env: dev' -H 'X-Tenant: default' \
-d '{"refresh_token":"01KYJ6KDXRRQBEYMEA9RNVTZGG.H2IPiD4ab6191qSm3mzroBDngDj3tO5NuYHKVGIBYlg"}'
{"ok":true}

Access tokens are not revoked by logout; they live out their 900 seconds. Instant revocation is a property of managed sessions only.

Schema changes over HTTP, the tenant registry and impersonation all need a control-plane token. You get one from the same endpoint by naming the reserved realm — the request keeps the same customer/product/env, and the reserved realm selects the plane governing them:

Terminal window
curl -s http://127.0.0.1:5030/auth/login \
-H 'X-Customer: default' -H 'X-Product: iam' -H 'X-Env: dev' -H 'X-Tenant: default' \
-d '{"identity":"[email protected]","password":"Passw0rd!","realm":"superadmin"}'

The payload of the token it returns:

{"exp":1785170741,"iat":1785169841,"iss":"iam","nbf":1785169841,"realm":"superadmin","roles":["superadmin","root"],"sub":"U01KYJ6HFJB5CJ975702J5HJ6WY","tenant":"default:iam:dev:superadmin","type":"user"}

tenant is the plane, not the tenant, and the token is signed by the plane’s own keyring — so control-plane calls carry X-Tenant: superadmin and the plane’s token, here $OPERATOR_TOKEN:

Terminal window
curl -s http://127.0.0.1:5030/superadmin/registry/tenants \
-H "Authorization: Bearer $OPERATOR_TOKEN" \
-H 'X-Customer: default' -H 'X-Product: iam' -H 'X-Env: dev' -H 'X-Tenant: superadmin'
{"data":[]}

The superadmin role is derived structurally from the realm and can never come from row data, so no tenant identity can ever carry it. The tenant admin token from step 5 gets:

{"error":"requires superadmin role","code":"v2.requires_superadmin"}

That is the gate working. Superadmin covers grants, roles and what an operator can and cannot reach.

Getting a token without the rest of the fleet

Section titled “Getting a token without the rest of the fleet”

This whole page ran against one binary and one database. The dev profile deliberately wires nothing else, and each omission has a consequence worth knowing before you write a test against it:

Not configuredConsequence
Config server (config.url)The hives: block in the boot file is the config source.
Vault (vault.url)dbpools.password is used literally, and fields marked encrypted at rest are stored in plaintext, silently — no vault client means the encryption hooks are simply not installed.
Metadata service (meta.url / meta.localdir)No product layer: one realm users, one provider password, no custom claims, no schema extensions.
Service registryA dummy client registers; nothing else changes.
Notification service (notify.url)Password-reset and magic-link requests still return {"ok":true} and the codes are generated, persisted and then dropped. Read them from the database or wire a sink.

Two more limits of any boot, dev or not:

  • Federated login is not reachable by configuration. OAuth 2.0 and WebAuthn are implemented and tested, but the deployable binary registers no provider and enables no relying party: every OAuth route answers 404 unknown_provider and every WebAuthn route answers 503 webauthn_disabled. There is no config key that changes this. See OAuth and Passwordless.
  • There is no one-time-code login provider — not over SMS, email or any other channel. Password, magic link and TOTP as a second factor are the credential surfaces that ship.

For a machine caller, issue an API key with POST /auth/apikey and exchange it at POST /auth/token for a short service token; a key’s capabilities can never exceed the issuer’s own authority, so a tenant admin asking for a scope list gets 403 scope_exceeds_authority. See API keys.

ResponseCause
400 · invalid tenant (as application/text)One or more of the four X-* headers is missing. This one is not the JSON envelope.
401 · {"code":"invalid_credentials","error":"invalid identity or password"}Wrong identity, wrong password, or the identity has no password. Identical in all three cases, and the not-found path burns the same hashing budget so timing does not leak.
401 · Unauthorized (plain text)No Authorization header on a protected route.
401 · {"code":"invalid_token","error":"token validation failed"}The CPET headers do not match the token’s tenant claim, or jwks.issuers.iam is missing or unreachable.
401 · {"code":"token_reuse",…}A rotated refresh token was replayed. The family is revoked; log in again.
403 · {"code":"already_bootstrapped",…}POST /auth/bootstrap when an operator already exists, when bootstrap.token is unset, or when it is wrong — one indistinguishable answer for all three.
403 · {"code":"v2.requires_superadmin",…}A tenant token on a route raised to operator authority (plan apply, seeds, locks, the registry).
500 · {"code":"engine_unavailable","error":"could not reach datastore"}The CPET resolves to no tenant config, or the pool build or ping failed. The body is deliberately generic — the cause is in the log, which is at level error by default and therefore visible.
429 + Retry-AfterThe built-in per-tenant, per-IP limiter. It is in-memory and per-instance, with no config key.

Full list on the error reference.

  • Password login — identifiers, realms, and what the request body accepts.
  • Realms — authenticating a product’s own entity instead of users.
  • MFA — TOTP enrolment and the second step of a login.
  • Tokens — the claim set, custom claims, signing keys and rotation.
  • Entities — the identity model and how a product extends it.
  • IAM API reference — every route, method and body.