Kaiten

Developer Resources

Authentication

The three credential families — identity-provider JWTs, ksh_ access tokens, and ksm_ platform credentials — the identity model behind them, and the token lifecycle from issue to revocation.

Three families of credential reach Kaiten, all sent as Authorization: Bearer <credential>. Which one you hold decides which surface answers you.

FamilyFormatVerified byCarries an organizationReaches
Identity-provider JWTA standard JWTThe gateway's jwt_authn filter, against your issuer's JWKSYes, in a claimCore API
Access tokenksh_ + 32 random bytesThe gateway's ext_authz filter, exchanged at the API's validation endpoint for an internal JWTYesCore API
Platform credentialksm_ + 32 random bytesThe Platform listener itself — no gateway involvedNo, by designPlatform API only, on its own port

The first is what the console uses after you sign in; Kaiten Cloud handles sign-in for you, and a self-hosted installation brings its own OIDC provider. The second is what your integrations use. The third belongs to the operator of a self-hosted installation and does not exist for you on Kaiten Cloud — this page covers the first two, and the Platform API page covers the third.

The prefixes are disjoint, and that is load-bearing: ksh_ and ksm_ diverge at the third character, before any random content. An earlier draft nested them as ksh_adm_, and because the base64url alphabet contains _, an ordinary ksh_ token could legitimately begin with adm_ and authenticate on the wrong surface — roughly one token in sixteen million, silently and unreproducibly. Disjoint prefixes leave nothing to mitigate.

The identity model

Kaiten has one identity model for humans and machines:

  • Humans are provisioned from the identity provider and belong to organizations through memberships. They act in the console.
  • Service accounts are machine users that live inside one organization. They have a slug, no email, and hold access tokens. They are what your backend, CI pipeline, or CRM sync authenticates as.
  • system:kaiten is the platform identity: a machine user with no organization of its own, created by a migration rather than by the seeder so that it exists before the first organization does. A trigger makes it a member of every organization the moment that organization is created. It is what a platform credential authenticates as, and it is indestructible — the database refuses to delete or alter it, because every platform credential resolves through that row. Its slug contains a :, which the service-account slug pattern forbids, so an organization can neither collide with it nor produce it, and it appears in no service-account listing.

Every create and update in the API records the acting identity — responses carry createdBy and updatedBy, whether the actor was a person, a service account, or the platform identity. That attribution is what makes the audit story work.

Create a service account

POST /service-accounts — requires the write:tokens scope (your console session has it; so does any token granted it).

curl -X POST http://localhost:6060/api/service-accounts \
  -H "Authorization: Bearer $KAITEN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Delivery Integration", "slug": "delivery-integration" }'
{
  "id": "7f1d2c3b-...",
  "name": "Delivery Integration",
  "slug": "delivery-integration",
  "externalId": "ksa_…",
  "createdAt": "2026-08-20T10:15:00Z",
  "createdBy": { /* the acting user */ },
  "tokens": null
}

The slug is the identifier every token call uses (^[a-z0-9][a-z0-9-]*[a-z0-9]$, unique per organization). Send it: a slug the API generates is the name followed by six random characters — delivery-integration-3f9a1c — and has to be read from this response before the next call.

The Kaiten Sushi Shop dataset already holds two service accounts. Ops Team (ops-team) is the account the seeder acts as, so every seeded record names it in createdBy; its token ops-team-token carries write: on instances, licenses, customers, deployment zones, releases, components, organizations, tokens, entitlements, feature flags, and metadata fields. SDK (sdk) holds sdk-token, with read:feature_flags and read:entitlements. The seeder shows neither token's secret: issue a new token, on one of these accounts or on your own, to call the API.

Issue a token

POST /service-accounts/{serviceAccountSlug}/tokens — name the token for what will hold it, and grant only the scopes it needs.

curl -X POST http://localhost:6060/api/service-accounts/delivery-integration/tokens \
  -H "Authorization: Bearer $KAITEN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "production",
    "slug": "production",
    "scopes": ["read:feature_flags", "write:instances"]
  }'
{
  "id": "a9c4e8d1-...",
  "name": "production",
  "slug": "production",
  "token": "ksh_3fK9…",
  "scopes": ["read:feature_flags", "write:instances"],
  "createdAt": "2026-08-20T10:16:00Z",
  "createdBy": { /* the acting user */ }
}

The token value appears only in this response and cannot be retrieved again — it is stored as a salted verification hash, never in plaintext. If a token is lost, revoke it and issue a new one.

The Token Created page for the production token of the Delivery Integration service account: the ksh_ token, shown once, with its copy button and its detailsThe Token Created page for the production token of the Delivery Integration service account: the ksh_ token, shown once, with its copy button and its details

Use the token

curl http://localhost:6060/api/instances \
  -H "Authorization: Bearer ksh_3fK9…"

The token's scopes gate every operation — the required scope for each endpoint is listed on the operation, and write:<module> implies read:<module>. This token lists instances because write:instances implies read:instances; the same call on /customers answers 403 Auth.MissingScope, since nothing it carries grants read:customers. The full grammar is on the scopes page.

Lifecycle

PropertyBehavior
Formatksh_ prefix + random body; the prefix is how the gateway recognizes an access token and routes it to the exchange
StorageTwo hashes, for two jobs. A deterministic SHA-256 lookup_hash finds the row — a partial unique index makes that an index-only read — and a salted bcrypt hash verifies the secret. The plaintext exists once, in the creation response.
KindEvery credential row carries a kind: organization or platform. An organization credential must name an organization; a platform credential must not, and must belong to the platform identity. The database enforces both directions, so "has no organization" is a checked state rather than a zero UUID waiting to be dereferenced.
ExpiryOptional at creation; omitted means non-expiring, and its bound is revocation
RevocationDELETE /service-accounts/{serviceAccountSlug}/tokens/{tokenSlug} → the token stops authenticating immediately; the row remains, with who revoked it and when
RetentionA revoked or expired row is swept after 90 days (KAITEN_RETENTION_RETIRED_TOKENS), not 7: the row is the audit evidence that the credential existed and when it stopped working, which outlives any debugging window
ListingGET .../tokens returns metadata only — secret values are never returned
Multiple tokensA service account holds any number of tokens, each with its own name, scopes, and expiry — rotate by issuing the next one before revoking the last

One failure message, on purpose

Every validation failure answers the same way — unknown prefix, no such row, hash mismatch, expired credential. The caller learns that the credential did not work, and nothing about which family it was measured against. The same reasoning gives credential-class mismatches a single shared code, Auth.WrongCredentialKind, which the Platform listener answers to any other credential before routing, whatever the path: distinct codes would let a caller holding the wrong class of credential discover, path by path, which paths are registered operations. A ksm_ credential presented to the Core API never gets that far — it is not a JWT, and is refused as one.

Credentials issued by the operator

A self-hosted operator can mint an ordinary access token inside an organization without being a member of it, using a platform credential on the Platform API. The credential that results is unremarkable: ksh_, organization-scoped, owned by system:kaiten, and it authenticates exactly like one your own console issued. Three things distinguish it, and they matter if you see one in your organization:

  • It is announced. Minting emits SYSTEM_ORGANIZATION_TOKEN_ISSUED into your organization's event stream, in the same transaction that creates the credential. A credential now exists in your name and you were told. Subscribe to it — consuming events or webhooks — if you want to be notified.
  • Its scopes can only narrow. They must be a subset of the platform credential's own; asking for more is refused.
  • Its lifetime is bounded by its parent. The row records which platform credential issued it, and revoking that parent revokes the child. For a non-expiring credential, that cascade is the bound. Only an organization credential can have such a parent — a platform credential never has one, which is why the first one has to be minted database-side.

You cannot mint one of these from the Core API, and neither can any credential you hold. The reverse also holds: the operator's platform credential cannot read or write anything in your organization directly — it has no organization execution context, so every handler that needs one refuses it. Acting inside an organization always means minting a credential that is scoped to it.

Where to go next

On this page