Sessions
Every tenant runs one of two session models, and iam.svc picks between them from the tenant’s
compliance preset.
| Stateless | Managed | |
|---|---|---|
| Login returns | token + refresh_token | opaque session_id + cookie |
| Client holds | a self-contained PASETO | a reference the server can look up |
| Access token | minted at login, TTL from token.expiry (15m default) | minted per exchange, fixed 10m |
| Refresh | POST /auth/refresh rotates | none — refresh is refused |
| Revocation | wait out the access TTL, drop the refresh family | immediate, at the next exchange |
| Concurrency cap | none | max_concurrent_sessions |
| Idle timeout | none | idle_timeout_s |
| Absolute cap | none | absolute_expiry_s, 24h floor |
| Server-side row | none | one session row per live session |
A stateless tenant writes no session rows at all. Everything after the preset section applies to a managed tenant, plus impersonation sessions, which are managed on every tenant regardless of preset.
The preset
Section titled “The preset”session.preset is the single choice. It sets the model, the crypto suite, and both timeout
defaults coherently.
| Preset | Model | Idle default | Absolute default | Suite resolved |
|---|---|---|---|---|
standard (default) | stateless | none | none | v4 |
hipaa | managed | 900s (15m) | 43200s (12h) | v4 |
fedramp | managed | 600s (10m) | 28800s (8h) | v3 |
An unrecognised preset falls back to standard. Explicitly set fields always beat preset defaults.
Configuring a tenant
Section titled “Configuring a tenant”The session: block sits on the tenant node, a sibling of datastores: and dbpools:. In
development it is inline in the boot file; in production it comes from
config.svc alongside the tenant’s pool and signing key.
hives: tenant: - path: default # default:iam:dev:default name: default active: true session: preset: hipaa # standard | hipaa | fedramp max_concurrent_sessions: 1 # 1 = single-session ("one device") on_limit: evict_oldest # or reject idle_timeout_s: 900 # 15m absolute_expiry_s: 43200 # 12h datastores: main: dbpool: mainpoolpreset: alone is enough; the other keys tune it.
| Key | Type | Default | Meaning |
|---|---|---|---|
session.preset | string | standard | standard · hipaa · fedramp |
session.model | string | preset-derived | stateless · managed; raw override of the model axis |
session.crypto_suite | string | preset-derived | v4 · v3; parsed and resolved, never read by the mint |
session.max_concurrent_sessions | int | 0 | Cap per principal; 0 = unlimited, 1 = single-session |
session.on_limit | string | evict_oldest | reject (409) or evict_oldest |
session.idle_timeout_s | int | preset default | Seconds of inactivity; 0 = none |
session.absolute_expiry_s | int | preset default | Hard cap in seconds; the registry still imposes 24h if this resolves to 0 |
Session policy composes in this order, last wins: built-in defaults → the product’s behavior YAML →
tenant overlay rows → the tenant’s session: config block. The preset is then applied to fill any
field still unset. The preset is provisioning-set — no tenant-facing API changes it.
iam.svc config generate bootstrap-managed-session prints a runnable dev config with this block
already set.
What login returns
Section titled “What login returns”On a managed tenant the authenticating flows return the session reference and nothing else. There is no access token and no refresh token in the response.
curl -s localhost:5030/auth/login \ -H 'X-Customer: default' -H 'X-Product: iam' -H 'X-Env: dev' -H 'X-Tenant: default' \{ "session_id": "0Xb1…", "expires_at": "2026-07-27T22:14:03Z",}expires_at is the session’s absolute expiry, not a token lifetime. session_id is 32 bytes of
CSPRNG output, base64url without padding. It is the credential — its unguessability is its only
security property — and no enumeration ever returns it.
The same reference is also set as a cookie:
| Attribute | Value |
|---|---|
| Name | kis_session |
| Value | the opaque session_id |
Path | / |
Expires | the session’s absolute expiry |
HttpOnly | set |
Secure | set |
SameSite | Lax |
No Domain and no Max-Age are set. Native and CLI clients read session_id from the body and
ignore the cookie.
Which flows take the managed branch
Section titled “Which flows take the managed branch”One shared fork runs on every flow that authenticates a user, so enabling MFA cannot silently drop a tenant back to stateless tokens.
| Flow | Route | auth_method recorded | AAL |
|---|---|---|---|
| Password | POST /auth/login | password | 1 |
| MFA completion | POST /auth/mfa/verify | totp | 2 |
| Magic link | POST /auth/magic/verify | magic_link | 1 |
| Passkey assertion | POST /auth/webauthn/assert/finish | webauthn | 2 |
| Federated callback | GET /auth/oauth/:provider/callback | idp:<provider> | 1 |
| Impersonation | POST /superadmin/impersonate | impersonation | 0 |
The passkey and federated rows are not reachable in the shipping binary: passkey routes answer
503 webauthn_disabled and OAuth routes answer 404 unknown_provider, and there is no
configuration path that changes either. See Authentication.
Password login short-circuits to the MFA-pending token before its managed branch, so on a
MFA-enrolled identity the session is created by /auth/mfa/verify and records AAL 2.
Impersonation always creates a session row, whatever the tenant’s preset, so the impersonated user’s session exists as something that can be listed and killed. It records no assurance level.
The exchange
Section titled “The exchange”The client never gets a token from login. A token is minted per request from the stored record.
curl -s localhost:5030/internal/session/exchange \ -H 'X-Customer: default' -H 'X-Product: iam' -H 'X-Env: dev' -H 'X-Tenant: default' \ -d '{"session_id":"<from login>"}'{ "token": "v4.public.…", "format": "v4.public", "expires_at": "2026-07-27T10:34:03Z", "cpet": "default:iam:dev:default"}In a deployment this call is made by the API gateway over internal mTLS, not by the browser.
The exchange checks, in order:
| Check | On failure |
|---|---|
A row exists for session_id | 401 invalid_session |
status is active | 401 invalid_session |
| The row’s tenant equals the request’s tenant | 401 invalid_session |
Now is before absolute_expiry | Row revoked absolute_expired, 401 invalid_session |
Now is within idle_timeout_s of last_seen_at | Row revoked idle_expired, 401 invalid_session |
Unknown, revoked, cross-tenant, idle-expired and absolute-expired are one indistinguishable 401 —
there is no oracle telling a caller which. The exchange then bumps last_seen_at and mints.
Facts about the minted token:
- The TTL is fixed at 10 minutes and ignores the tenant’s
token.expiry. Revocation latency is bounded by this TTL, which is why a managed tenant cannot lengthen it. - Claims come from the same builder login uses —
iss,sub,tenant,realm,type,iat,nbf,exp, plusroles, plusactwhen the session is an impersonation. See Tokens. - The identity row is re-read on every exchange. A deleted or deprovisioned identity cannot exchange its way to a new token, and a role change takes effect at the next exchange rather than the next login.
- The session’s assurance level is not carried into the token.
aalsits on the row and appears nowhere in the claim set, so step-up policy cannot be enforced from a token today. - The
audiencefield on the request body is accepted and ignored. Nothing setsaudon an exchanged token.
Concurrency limits
Section titled “Concurrency limits”When max_concurrent_sessions is greater than 0, the count and the insert run under a database
advisory lock keyed on the tenant and the principal, so two simultaneous logins cannot both take the
last slot.
on_limit | Behaviour at the cap |
|---|---|
evict_oldest (default) | Oldest sessions are revoked with reason concurrent_evict until the new one fits |
reject | The login fails with 409 session_limit; existing sessions are untouched |
The count is of rows whose status is active, taken at create time. Nothing else consults the cap.
Timeouts
Section titled “Timeouts”Both timeouts are snapshotted onto the row when the session is created, so changing the tenant’s policy does not retighten sessions that already exist.
idle_timeout_sis stored on the row and compared againstlast_seen_atat every exchange.0disables it.absolute_expiry_sis materialised as anabsolute_expirytimestamp at create and wins over activity. If it resolves to 0 — astandardpreset forced tomodel: managed, say — the registry still stamps 24 hours. A managed session always has a hard cap.
The tenancy guard
Section titled “The tenancy guard”The tenant coordinate on the row is server-set from the authenticated identity at login and is re-checked against the request’s resolved tenant on every exchange. A mismatch is the same opaque 401. A session bound to one tenant can only ever mint tokens scoped to that tenant, and the client has no channel to assert a tenant of its own.
Revocation, rotation, listing
Section titled “Revocation, rotation, listing”| Method | Path | Purpose | Response |
|---|---|---|---|
POST | /internal/session/exchange | Introspect + mint | 200 result · 401 invalid_session · 400 bad_request |
POST | /internal/session/revoke | Kill by control-plane id | 204 · 400 bad_request · 500 revoke_failed |
POST | /internal/session/rotate | New reference, old one revoked | 200 {session_id, absolute_expiry} · 404 unknown_session |
GET | /internal/session/list?principal=<id> | A principal’s active sessions | 200 {sessions:[…]} · 400 bad_request |
Revoke and rotate take the session row’s control-plane id, not the opaque reference, and list
returns that id. The bearer credential is presented by its holder at /exchange and appears nowhere
else, so enumerating sessions cannot leak another session’s credential.
list returns id, principal_id, cpet, status, aal, auth_method, user_agent_label,
created_at and last_seen_at for each row still marked active. The user-agent label is display
metadata, not a security control — nothing binds a session to it.
Revocation is immediate in the sense that matters: the next exchange fails. A token already minted from that session stays valid until its 10-minute TTL runs out.
revoked_reason records why a row left active: user_logout, admin_revoke, concurrent_evict,
idle_expired, absolute_expired, rotated.
Rotation carries the whole prior context across — realm, auth method, IdP fields, and all three
impersonation fields — and optionally raises the assurance level. The old row is revoked with reason
rotated.
A managed tenant has no refresh tokens
Section titled “A managed tenant has no refresh tokens”POST /auth/refresh on a managed tenant answers 403 managed_session and mints nothing. It is a
refusal, not a conversion: minting would hand back a self-contained token the registry knows nothing
about, and creating a session from an old refresh token would invent one from a credential model the
tenant has left behind. A refresh request against a managed tenant is either a token issued before
the tenant moved to the preset, or an attack.
Failure modes
Section titled “Failure modes”The block refuses rather than downgrading. The first two exist because silently issuing a stateless token on a regulated tenant is invisible.
| Status | Code | When |
|---|---|---|
503 | policy_unavailable | The tenant’s session policy could not be resolved. The service will not guess stateless |
500 | session_service_unavailable | The tenant is managed but no session service is wired in this deployment |
500 | session_failed | Session creation failed for any other reason, including on impersonation, where a token without its session is refused outright |
409 | session_limit | The concurrency cap was reached with on_limit: reject |
401 | invalid_session | Exchange refused — unknown, revoked, cross-tenant, idle-expired or absolute-expired |
404 | unknown_session | Rotate targeted a row id that does not exist |
500 | exchange_failed / revoke_failed / rotate_failed / list_failed | Internal failure on the matching endpoint |
403 | managed_session | Refresh attempted on a managed tenant |
The session surface uses the flat {"error", "code"} envelope. Full catalogue on the
error page.
Not on the surface today
Section titled “Not on the surface today”- Back-channel logout. The
sessionentity carriesidp_issandidp_sidcolumns and a composite index for correlating an IdP logout, andbackchannel_logoutis a documented revocation reason. No endpoint accepts a logout token. The federated callback leaves both fields empty because it does not validate the ID token, so even federated sessions carry nothing to correlate on. - Session-lifecycle audit export. Create, revoke, rotate and expire emit in-process auth events only; nothing exports them to the audit service.
- Tenant-facing session management. Listing and revoking exist only on the mesh-internal surface above.
Continue with
Section titled “Continue with”- Tokens — the claim set, PASETO format, and the per-tenant keyring
- Authentication — the login flows that create a session
- Authorization — how
rolesandactare read at the receiver - Impersonation — the force-managed operator session
- Operating the block — boot config, wiring, and failure modes
- IAM API reference — every endpoint, request and response shape