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.
What you need
Section titled “What you need”| Requirement | Detail |
|---|---|
iam.svc | The single binary. iam version prints the build. |
| A PostgreSQL database | Reachable, already created. iam.svc creates schemas, never databases. |
curl | Or 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.
1. Generate a bootstrap file
Section titled “1. Generate a bootstrap file”Five config profiles are embedded in the binary:
iam.svc config listConfig 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:
iam.svc config generate bootstrap-dev > .iam.yamlIt 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:
| Key | Value in the profile | Why it matters |
|---|---|---|
port | 5030 | Required. Boot aborts with invalid/empty port provided if it is empty in both the flag and the file. |
host | 127.0.0.1 | Empty binds every interface. |
zerotrust | false | Defaults to true when the key is absent — and then the listener demands TLS material and fails to start. |
jwks.issuers.iam | http://127.0.0.1:5030 | Top-level, a sibling of host/port. This service verifies its own tokens through it. |
hives.cluster[].path: "" | carries bootstrap.token | The service’s own cluster node. |
hives.tenant[] | one node per plane | Each 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: 5Set 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: info2. Create the tables
Section titled “2. Create the tables”The database must exist. The PostgreSQL schema is created for you.
iam.svc data bootstrap -f .iam.yamlPlan 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: U18C6326CCBE386380000000001status: appliedapplied: 61 failed: 0 skipped: 0The control plane is a separate schema with its own tables, and it migrates separately:
iam.svc data bootstrap -f .iam.yaml --tenant default:iam:dev:superadminplan_id: U18C6326CEEC7E9A00000000001status: appliedapplied: 34 failed: 0 skipped: 0Both are idempotent — every step is IF NOT EXISTS, so re-running changes nothing. Add --dry-run
to print the plan without applying it.
| Fact | Detail |
|---|---|
--tenant | Always four segments. --tenant superadmin is rejected: invalid --tenant "superadmin" (want customer:product:env:tenant). |
Default --tenant | default:iam:dev:default on data and seed. |
| DDL is never silent | Every statement is printed before it is applied. |
| One CPET per run | There is no all-tenants pass; roll a fleet with your own loop. |
| After bootstrap | Steady-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.
3. Create the first identities
Section titled “3. Create the first identities”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:
iam.svc seed -f .iam.yaml --email [email protected] --password 'Passw0rd!' --firstname Ada --lastname Lovelacesuperadmin: created id=U01KYJ6HFJB5CJ975702J5HJ6WY [email protected]admin user: created id=U01KYJ6HG0Q4RKHJCQMBKTZNYNQ [email protected] roles=[admin]Two rows in two planes:
| Row | Lands in | Roles |
|---|---|---|
| operator | the control plane default:iam:dev:superadmin | root |
| tenant admin | the tenant’s users entity | properties.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 — skippedadmin user: already exists — skippedPass --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.
4. Start the service
Section titled “4. Start the service”iam.svc -f .iam.yamliam service started at 127.0.0.1:5030One listener, one port. There is no separate admin, metrics or pprof port.
curl -s http://127.0.0.1:5030/readyready:truecurl -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.
5. Log in
Section titled “5. Log in”Every route except /ready and /health needs the four tenant headers.
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' \{ "token": "v4.public.eyJleHAiOjE3ODUxNzA2NzYsImlhdCI6MTc4NTE2OTc3NiwiaXNzIjoiaWFtIiwibmJmIjoxNzg1MTY5Nzc2LCJyZWFsbSI6InVzZXJzIiwicm9sZXMiOlsiYWRtaW4iXSwic3ViIjoiVTAxS1lKNkhHMFE0UktISkNRTUJLVFpOWU5RIiwidGVuYW50IjoiZGVmYXVsdDppYW06ZGV2OmRlZmF1bHQiLCJ0eXBlIjoidXNlciJ9aTno22fjHMo0FEp_4O2ZbgyDTlzwWcTOwzdgAHIJwCPYZYx8Dz4xFfZYqQasLfQeTo9dp6PPadNto9nr18r-DQ.eyJraWQiOiIwMUtZSjZKM0VHQTdaQzZaUUEwNTkzTkZGOSJ9", "refresh_token": "01KYJ6J3EKHQ8ZDMKRSYT5FVFW.H-MlfkEFrDR9YOfEFpeVT3e2xuSO-gmHmA2YWIMXjKI", "user": { "id": "U01KYJ6HG0Q4RKHJCQMBKTZNYNQ", "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"}| Claim | Value here | Meaning |
|---|---|---|
iss | iam | Issuer. Resource servers map it to a JWKS URL. |
sub | U01K… | The identity row id in the authenticating realm’s entity. |
tenant | default:iam:dev:default | The full CPET the token is valid for. |
realm | users | The realm that authenticated. |
roles | ["admin"] | Read from the identity row’s properties.roles. Omitted when empty. |
type | user | service for exchanged API keys, mfa_pending for the intermediate MFA token. |
exp / iat / nbf | — | expires_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:
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.
6. Use the token
Section titled “6. Use the token”Keep the access token in a variable — the rest of this page uses $TOKEN:
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"])')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", "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 theunmaskaction, which only the service’s own internal principal holds. createdby/createdon/updatedby/updatedonare injected on every entity, along with soft-delete columns. Nothing opts out.- The tenant admin sees every row because the
usersread filter returns unrestricted foradmin. 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.
7. Refresh, then log out
Section titled “7. Refresh, then log out”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:
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.
8. Log in as the operator
Section titled “8. Log in as the operator”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:
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' \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:
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 configured | Consequence |
|---|---|
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 registry | A 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_providerand every WebAuthn route answers503 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.
When it does not work
Section titled “When it does not work”| Response | Cause |
|---|---|
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-After | The built-in per-tenant, per-IP limiter. It is in-memory and per-instance, with no config key. |
Full list on the error reference.
Continue with
Section titled “Continue with”- 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.