Skip to content
Talk to our solutions team

Sessions

Every tenant runs one of two session models, and iam.svc picks between them from the tenant’s compliance preset.

StatelessManaged
Login returnstoken + refresh_tokenopaque session_id + cookie
Client holdsa self-contained PASETOa reference the server can look up
Access tokenminted at login, TTL from token.expiry (15m default)minted per exchange, fixed 10m
RefreshPOST /auth/refresh rotatesnone — refresh is refused
Revocationwait out the access TTL, drop the refresh familyimmediate, at the next exchange
Concurrency capnonemax_concurrent_sessions
Idle timeoutnoneidle_timeout_s
Absolute capnoneabsolute_expiry_s, 24h floor
Server-side rownoneone 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.

session.preset is the single choice. It sets the model, the crypto suite, and both timeout defaults coherently.

PresetModelIdle defaultAbsolute defaultSuite resolved
standard (default)statelessnonenonev4
hipaamanaged900s (15m)43200s (12h)v4
fedrampmanaged600s (10m)28800s (8h)v3

An unrecognised preset falls back to standard. Explicitly set fields always beat preset defaults.

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: mainpool

preset: alone is enough; the other keys tune it.

KeyTypeDefaultMeaning
session.presetstringstandardstandard · hipaa · fedramp
session.modelstringpreset-derivedstateless · managed; raw override of the model axis
session.crypto_suitestringpreset-derivedv4 · v3; parsed and resolved, never read by the mint
session.max_concurrent_sessionsint0Cap per principal; 0 = unlimited, 1 = single-session
session.on_limitstringevict_oldestreject (409) or evict_oldest
session.idle_timeout_sintpreset defaultSeconds of inactivity; 0 = none
session.absolute_expiry_sintpreset defaultHard 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.

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.

Terminal window
curl -s localhost: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!"}'
{
"session_id": "0Xb1…",
"expires_at": "2026-07-27T22:14:03Z",
"user": { "id": "U01K…", "email": "[email protected]", "displayname": "Admin User" }
}

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:

AttributeValue
Namekis_session
Valuethe opaque session_id
Path/
Expiresthe session’s absolute expiry
HttpOnlyset
Secureset
SameSiteLax

No Domain and no Max-Age are set. Native and CLI clients read session_id from the body and ignore the cookie.

One shared fork runs on every flow that authenticates a user, so enabling MFA cannot silently drop a tenant back to stateless tokens.

FlowRouteauth_method recordedAAL
PasswordPOST /auth/loginpassword1
MFA completionPOST /auth/mfa/verifytotp2
Magic linkPOST /auth/magic/verifymagic_link1
Passkey assertionPOST /auth/webauthn/assert/finishwebauthn2
Federated callbackGET /auth/oauth/:provider/callbackidp:<provider>1
ImpersonationPOST /superadmin/impersonateimpersonation0

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 client never gets a token from login. A token is minted per request from the stored record.

Terminal window
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:

CheckOn failure
A row exists for session_id401 invalid_session
status is active401 invalid_session
The row’s tenant equals the request’s tenant401 invalid_session
Now is before absolute_expiryRow revoked absolute_expired, 401 invalid_session
Now is within idle_timeout_s of last_seen_atRow 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, plus roles, plus act when 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. aal sits on the row and appears nowhere in the claim set, so step-up policy cannot be enforced from a token today.
  • The audience field on the request body is accepted and ignored. Nothing sets aud on an exchanged token.

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_limitBehaviour at the cap
evict_oldest (default)Oldest sessions are revoked with reason concurrent_evict until the new one fits
rejectThe 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.

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_s is stored on the row and compared against last_seen_at at every exchange. 0 disables it.
  • absolute_expiry_s is materialised as an absolute_expiry timestamp at create and wins over activity. If it resolves to 0 — a standard preset forced to model: managed, say — the registry still stamps 24 hours. A managed session always has a hard cap.

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.

MethodPathPurposeResponse
POST/internal/session/exchangeIntrospect + mint200 result · 401 invalid_session · 400 bad_request
POST/internal/session/revokeKill by control-plane id204 · 400 bad_request · 500 revoke_failed
POST/internal/session/rotateNew reference, old one revoked200 {session_id, absolute_expiry} · 404 unknown_session
GET/internal/session/list?principal=<id>A principal’s active sessions200 {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.

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.

The block refuses rather than downgrading. The first two exist because silently issuing a stateless token on a regulated tenant is invisible.

StatusCodeWhen
503policy_unavailableThe tenant’s session policy could not be resolved. The service will not guess stateless
500session_service_unavailableThe tenant is managed but no session service is wired in this deployment
500session_failedSession creation failed for any other reason, including on impersonation, where a token without its session is refused outright
409session_limitThe concurrency cap was reached with on_limit: reject
401invalid_sessionExchange refused — unknown, revoked, cross-tenant, idle-expired or absolute-expired
404unknown_sessionRotate targeted a row id that does not exist
500exchange_failed / revoke_failed / rotate_failed / list_failedInternal failure on the matching endpoint
403managed_sessionRefresh attempted on a managed tenant

The session surface uses the flat {"error", "code"} envelope. Full catalogue on the error page.

  • Back-channel logout. The session entity carries idp_iss and idp_sid columns and a composite index for correlating an IdP logout, and backchannel_logout is 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.