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.
The states that ship
Section titled “The states that ship”| State | Column state | How it is reached | Password login |
|---|---|---|---|
| Absent | no row | The starting point | 401 invalid_credentials |
| Active | active: true, locked: false, deletedon null | Any of the four creation paths | Succeeds |
| Disabled | active: false | PATCH /admin/user/:id | Succeeds — the flag is not read |
| Locked | locked: true | PATCH /admin/user/:id | Succeeds — the flag is not read |
| Deleted | deletedon set | DELETE /admin/user/:id | 401 invalid_credentials |
| Purged | row gone | DELETE …?purge=true | 401 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.
Creation
Section titled “Creation”Four paths create an account. None of them is self-service.
| Path | Route or command | Creates | Roles |
|---|---|---|---|
| Admin CRUD | POST /admin/user or POST /rest/users | A users row in the caller’s tenant | Whatever the payload’s properties.roles carries |
| CLI seed | iam seed | A users row, and by default the first operator | ["admin"] unless --role is repeated |
| Tenant provisioning | POST /superadmin/registry/tenants/provision | The tenant’s first admin, written under the internal service identity | ["admin"] |
| OAuth just-in-time | GET /auth/oauth/:provider/callback | A users row for an unknown email | none |
POST /admin/user is gated by the users create rule (admin or iam-service) — an ordinary
user token cannot create an account.
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:
iam seed --tenant acme:shop:prod:eu1 --email [email protected] --password 'S3cret!' --firstname Ada --lastname Lovelace --role adminiam 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 just-in-time OAuth path
Section titled “The just-in-time OAuth path”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.
What has no lifecycle stage at all
Section titled “What has no lifecycle stage at all”| Stage you may expect | What ships |
|---|---|
| Invitation | No entity, no route, no code. There is no invite token, no pending-invite state, no accept flow |
| Email verification | Not implemented. email is required and unique; nothing confirms it |
| Phone verification | Not implemented. There is no OTP provider in the service |
| Self-service registration | No signup route. A caller cannot create their own account |
| Activation step | A created row is already active: true; there is nothing to activate |
| Password expiry or forced rotation | Not 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.
Disable and lock
Section titled “Disable and lock”Both are column writes on the identity row, through the ordinary update surface:
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}'| Write | Effect that ships | Effect that does not exist |
|---|---|---|
active: false | Blocks 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: true | None. No code reads the column | Does not block anything |
A user_lock row | None. The entity is unreferenced by any code | Does 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.
Implementing lockout yourself
Section titled “Implementing lockout yourself”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.
| Event | Fires | Denying returns |
|---|---|---|
before_login | After the realm and identity type resolve, before the identity lookup | 403 hook_denied |
check_login_abuse | After before_login, before the identity lookup | 429 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 and restore
Section titled “Delete and restore”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.
| Operation | Route | Result |
|---|---|---|
| Soft delete | DELETE /admin/user/:id or DELETE /rest/users/id/:id | Stamps deletedon and deletedby; the row stops being readable |
| Read deleted | GET /rest/users?deleted=true | Includes soft-deleted rows |
| Restore | POST /rest/users/id/:id/restore | Clears deletedon and deletedby |
| Hard delete | DELETE /rest/users/id/:id?purge=true | Removes 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.
What a delete does and does not revoke
Section titled “What a delete does and does not revoke”| Credential | After the delete |
|---|---|
| An issued access token | Still valid until exp — the default access lifetime is 15 minutes and tokens are self-contained |
| Refresh token | Dead at the next call: refresh re-reads the identity row and returns 401 invalid_token |
| Managed session | Dead at the next exchange: the mint re-reads the identity row and returns 500 exchange_failed |
| API key | Unaffected — an api_key belongs to a service, not a user, and carries no owner column |
Stored rows keyed by userid | Not 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.
| Flow | Active | Disabled (active: false) | Locked (locked: true) | Deleted | No such account |
|---|---|---|---|---|---|
POST /auth/login | 200 token pair | 200 token pair | 200 token pair | 401 invalid_credentials | 401 invalid_credentials |
POST /auth/login with "realm": "superadmin" | 200 token pair | 401 invalid_credentials | 200 token pair | 401 invalid_credentials | 401 invalid_credentials |
POST /auth/refresh | 200 new pair | 200 new pair | 200 new pair | 401 invalid_token | 401 invalid_token |
POST /internal/session/exchange | 200 token | 200 token | 200 token | 500 exchange_failed | 401 invalid_session |
POST /auth/magic/request | 200 {"ok":true} + code sent | 200 + code sent | 200 + code sent | 200 {"ok":true}, nothing sent | 200 {"ok":true}, nothing sent |
POST /auth/magic/verify | 200 token pair | 200 token pair | 200 token pair | 401 invalid_magic | 401 invalid_magic |
POST /auth/password/reset/request | 200 + code sent | 200 + code sent | 200 + code sent | 200, nothing sent | 200, nothing sent |
POST /superadmin/impersonate (as target) | 200 if opted in | 404 no_such_user | 200 if opted in | 404 no_such_user | 404 no_such_user |
POST /auth/webauthn/assert/begin | 503 webauthn_disabled | 503 webauthn_disabled | 503 webauthn_disabled | 503 webauthn_disabled | 503 webauthn_disabled |
GET /auth/oauth/:provider/start | 404 unknown_provider | 404 unknown_provider | 404 unknown_provider | 404 unknown_provider | 404 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.
Error codes by cause
Section titled “Error codes by cause”| Code | Status | Raised when |
|---|---|---|
invalid_credentials | 401 | One 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_token | 401 | The refresh token is unknown, or the identity behind it no longer resolves |
token_expired | 401 | The refresh token’s expireson has passed, or is missing — a missing value counts as expired |
token_reuse | 401 | A rotated refresh token was replayed; the whole rotation family is revoked |
invalid_magic | 401 | The magic-link code is unknown or expired, or the identity behind it no longer resolves |
invalid_session | 401 | The session id is unknown, revoked, idle-expired or absolute-expired — one envelope, no oracle on which |
exchange_failed | 500 | The session is valid but the mint failed — including because the identity behind it no longer resolves |
hook_denied | 403 | A before_login hook aborted the attempt |
login_abuse | 429 | A check_login_abuse hook denied the attempt |
impersonation_not_allowed | 403 | The target’s allow_impersonation is false |
no_such_user | 404 | No 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.
Related
Section titled “Related”- Identity entities — the
usersfield 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.rolesreaches a request - IAM API reference — request and response shapes for every route named here
- Errors — the full external error-code table