Skip to content
Talk to our solutions team

Account lifecycle

An account in iam.svc is a row in the realm’s identity entity — users by default. It has three lifecycle columns (active, locked, deletedon) and exactly one of them changes what a login attempt does.

StateColumn stateHow it is reachedPassword login
Absentno rowThe starting point401 invalid_credentials
Activeactive: true, locked: false, deletedon nullAny of the four creation pathsSucceeds
Disabledactive: falsePATCH /admin/user/:idSucceeds — the flag is not read
Lockedlocked: truePATCH /admin/user/:idSucceeds — the flag is not read
Deleteddeletedon setDELETE /admin/user/:id401 invalid_credentials
Purgedrow goneDELETE …?purge=true401 invalid_credentials

Two paths do filter on active: true: operator login (POST /auth/login with "realm": "superadmin") and the impersonation target lookup. Neither reads locked.

There is no state machine object, no status enum on users, and no transition validation. Every transition below is a plain column write through the entity surface.

Four paths create an account. None of them is self-service.

PathRoute or commandCreatesRoles
Admin CRUDPOST /admin/user or POST /rest/usersA users row in the caller’s tenantWhatever the payload’s properties.roles carries
CLI seediam seedA users row, and by default the first operator["admin"] unless --role is repeated
Tenant provisioningPOST /superadmin/registry/tenants/provisionThe tenant’s first admin, written under the internal service identity["admin"]
OAuth just-in-timeGET /auth/oauth/:provider/callbackA users row for an unknown emailnone

POST /admin/user is gated by the users create rule (admin or iam-service) — an ordinary user token cannot create an account.

Terminal window
curl -X POST https://iam.example.com/admin/user \
-H 'Authorization: Bearer <admin token>' \
-H 'X-Customer: acme' -H 'X-Product: shop' -H 'X-Env: prod' -H 'X-Tenant: eu1' \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","mobile":"+15555550100","firstname":"Ada","lastname":"Lovelace","middlename":"","avatar":"","password":"correct-horse-battery-staple","active":true}'

password is sent in plaintext and hashed to argon2id by a write hook before it reaches the database. middlename and avatar are passed as empty strings because a field without nullable: true is NOT NULL with no default, so omitting them fails the insert.

The CLI equivalent, which runs through the same engine and the same hooks:

Terminal window
iam seed --tenant acme:shop:prod:eu1 --email [email protected] --password 'S3cret!' --firstname Ada --lastname Lovelace --role admin

iam seed is idempotent on email: an existing row is left untouched. Pass --superadmin=false to seed only the tenant admin. See Operating for tenant onboarding.

The OAuth callback creates an account for an unknown email: a random 32-byte password it never reveals, the sentinel mobile, the IdP display name split into firstname / lastname (falling back to OAuth / User so the minimum-length validations pass), and active: true. It is the only automatic account creation in the service, and the only place the before_signup / after_signup events fire.

No OAuth provider is wired in the deployable binary — every OAuth route answers 404 unknown_provider, so no shipped deployment reaches this path. See OAuth.

Stage you may expectWhat ships
InvitationNo entity, no route, no code. There is no invite token, no pending-invite state, no accept flow
Email verificationNot implemented. email is required and unique; nothing confirms it
Phone verificationNot implemented. There is no OTP provider in the service
Self-service registrationNo signup route. A caller cannot create their own account
Activation stepA created row is already active: true; there is nothing to activate
Password expiry or forced rotationNot implemented

An account is therefore usable the instant it is written. There is no unverified state and no error code for one — a request against an unverified address is indistinguishable from any other, because the service never records verification.

Both are column writes on the identity row, through the ordinary update surface:

Terminal window
curl -X PATCH https://iam.example.com/admin/user/U01HX0000000000000000000000 \
-H 'Authorization: Bearer <admin token>' \
-H 'X-Customer: acme' -H 'X-Product: shop' -H 'X-Env: prod' -H 'X-Tenant: eu1' \
-H 'Content-Type: application/json' \
-d '{"active": false}'
WriteEffect that shipsEffect that does not exist
active: falseBlocks operator login (control plane only) and makes the user an invalid impersonation target (404 no_such_user)Does not block tenant password login, magic link, refresh, or session exchange
locked: trueNone. No code reads the columnDoes not block anything
A user_lock rowNone. The entity is unreferenced by any codeDoes not block anything

PATCH /admin/user/:id requires the admin or iam-service role — there is no rls.update rule on users, so a user cannot disable or lock their own account either.

The service ships two gate hooks on the login flow. Its bundled hook declaration carries an empty events: list, so neither does anything until you declare a script against it.

EventFiresDenying returns
before_loginAfter the realm and identity type resolve, before the identity lookup403 hook_denied
check_login_abuseAfter before_login, before the identity lookup429 login_abuse

A script bound to either can read the identity, consult your own counter or the locked column, and abort. That is the supported way to get lockout behaviour today; the locked column on its own buys nothing.

DELETE is a soft delete on every IAM entity, because the kisai.softdelete trait puts deletedon and deletedby on all of them. The row is UPDATEd, not removed, and every subsequent read filters it out.

OperationRouteResult
Soft deleteDELETE /admin/user/:id or DELETE /rest/users/id/:idStamps deletedon and deletedby; the row stops being readable
Read deletedGET /rest/users?deleted=trueIncludes soft-deleted rows
RestorePOST /rest/users/id/:id/restoreClears deletedon and deletedby
Hard deleteDELETE /rest/users/id/:id?purge=trueRemoves the row

A soft-deleting delete returns the row with deletedon and deletedby already set. There is no pre-image; capture the row first if you need its prior state. The change lands in users_audit as a delete operation — see Identity entities.

CredentialAfter the delete
An issued access tokenStill valid until exp — the default access lifetime is 15 minutes and tokens are self-contained
Refresh tokenDead at the next call: refresh re-reads the identity row and returns 401 invalid_token
Managed sessionDead at the next exchange: the mint re-reads the identity row and returns 500 exchange_failed
API keyUnaffected — an api_key belongs to a service, not a user, and carries no owner column
Stored rows keyed by useridNot cascaded. Refresh-token, MFA, magic-link and passkey rows survive the delete

Deleting a user does not enumerate and revoke their sessions. If you need the sessions gone immediately rather than at the next mint, revoke them explicitly — see Sessions.

What a login attempt returns in each state

Section titled “What a login attempt returns in each state”

Every row below is the response for one account state, per flow. All identity lookups run under the soft-delete filter, so the deleted column is uniform.

FlowActiveDisabled (active: false)Locked (locked: true)DeletedNo such account
POST /auth/login200 token pair200 token pair200 token pair401 invalid_credentials401 invalid_credentials
POST /auth/login with "realm": "superadmin"200 token pair401 invalid_credentials200 token pair401 invalid_credentials401 invalid_credentials
POST /auth/refresh200 new pair200 new pair200 new pair401 invalid_token401 invalid_token
POST /internal/session/exchange200 token200 token200 token500 exchange_failed401 invalid_session
POST /auth/magic/request200 {"ok":true} + code sent200 + code sent200 + code sent200 {"ok":true}, nothing sent200 {"ok":true}, nothing sent
POST /auth/magic/verify200 token pair200 token pair200 token pair401 invalid_magic401 invalid_magic
POST /auth/password/reset/request200 + code sent200 + code sent200 + code sent200, nothing sent200, nothing sent
POST /superadmin/impersonate (as target)200 if opted in404 no_such_user200 if opted in404 no_such_user404 no_such_user
POST /auth/webauthn/assert/begin503 webauthn_disabled503 webauthn_disabled503 webauthn_disabled503 webauthn_disabled503 webauthn_disabled
GET /auth/oauth/:provider/start404 unknown_provider404 unknown_provider404 unknown_provider404 unknown_provider404 unknown_provider

The last two rows are the same in every column because WebAuthn and OAuth are never enabled in the deployable binary — the route answers before any account is looked up.

POST /auth/login returns 200 with {"mfa_required": true, "mfa_token": …} instead of a token pair when the account has a TOTP enrolment, and {"session_id": …} instead of a token pair on a tenant using a managed session preset. Neither depends on account state. See MFA and Sessions.

CodeStatusRaised when
invalid_credentials401One envelope for four causes: no such account, deleted account, no password set, wrong password. All four spend the same argon2 budget so response timing does not separate them
invalid_token401The refresh token is unknown, or the identity behind it no longer resolves
token_expired401The refresh token’s expireson has passed, or is missing — a missing value counts as expired
token_reuse401A rotated refresh token was replayed; the whole rotation family is revoked
invalid_magic401The magic-link code is unknown or expired, or the identity behind it no longer resolves
invalid_session401The session id is unknown, revoked, idle-expired or absolute-expired — one envelope, no oracle on which
exchange_failed500The session is valid but the mint failed — including because the identity behind it no longer resolves
hook_denied403A before_login hook aborted the attempt
login_abuse429A check_login_abuse hook denied the attempt
impersonation_not_allowed403The target’s allow_impersonation is false
no_such_user404No active user with that id in the target tenant — impersonation only

There is no account_locked, no account_disabled, and no email_not_verified. None of those states is enforced, so none of them has a code.

  • Identity entities — the users field list, access rules and audit tables
  • Password login — the flow the states above are measured against
  • Tokens — lifetimes, refresh rotation, and what a token carries
  • Authorization — where roles live and how properties.roles reaches a request
  • IAM API reference — request and response shapes for every route named here
  • Errors — the full external error-code table