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 CEPT. The profile ships the reserved node as path: superadmin, which never binds: tenant nodes resolve by walking the prefixes of the key customer:env:product:tenant, and superadmin is not a prefix of default:dev:iam:superadmin. The control plane then silently shares the tenant’s own pool and schema.

hives:
tenant:
- path: default:dev:iam: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_schema_upgrade. CREATE TABLE IF NOT EXISTS "iam"."data_schema_upgrade...
…
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:dev:iam: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:env:product:tenant).
Default --tenantdefault:dev:iam:default on data and seed.
DDL is never silentEvery statement is printed before it is applied.
One CEPT 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:dev:iam: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": {},
"version": ""
}

The response also carries a memstats object. That is runtime diagnostics, not a contract, see API conventions. version is whatever the service set at startup, and most set nothing.

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:dev:iam: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:dev:iam:defaultThe full CEPT 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 / 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:

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"
}
],
"meta": { "entity": "users", "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/env/product, 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:dev:iam: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:

{"errors":[{"code":"requires_superadmin","message":"requires superadmin role"}]}

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 to check 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. To let a user give an external application, such as an AI agent, scoped access in their name, see App connections.

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 CEPT 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":"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 CEPT 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.