Documentation navigation
Token Mappers
This article explains the Keycloak protocol/token mappers that end up in the tokens our applications consume — which mappers exist on which client, where each claim comes from, and why the same logical claim is sometimes delivered differently depending on the client.
The picture is genuinely a bit messy, so §8 collects the inconsistencies — they are the reason the platform is migrating to Keycloak Organizations. For the consumer side — which repos read which part of the token and how — see Token Consumers.
This article describes the state being migrated away from. It stays accurate as a record of what is deployed today and of what must be unwound. For the target model see Platform Mappers Contract, and for the rollout see Organization Migration.
The clients, dedicated scopes, and mappers described here are created
programmatically via the Keycloak Admin API — by Corti Console
(console.corti.app) and platform-service / platform-api when a customer
creates an API client or a customer.
1. Overview
There are several distinct Keycloak clients, and a principal (a human user or
a service account) logs into each one separately. There is no token
brokering or exchange between them — e.g. an embedded client does not log
into the assistant client on the user's behalf. Each client issues its own
token, populated by its own dedicated mapper set.
| Client / entity | Who authenticates | Where |
|---|---|---|
| Direct API client (client credentials) | A service account (no human) | API integrations |
| Embedded Assistant client | A human user — their own or a company-provided username/password | Customer's embedded integration |
Studio showcase embedded client (*-default_embedded_client) |
An auto-created studio_user_<UUID>@users.corti.app |
Console embedded showcase |
assistant client |
A human user, with username/password | assistant.eu.corti.app |
A user can log into the standard Assistant and an embedded Assistant client separately and use the Assistant application either way.
All custom claims land under a nested corti object in the token (the
mapper's Token Claim Name is corti.<something>), e.g.:
{
"sub": "…",
"corti": {
"console_project_id": "nfm-reseller",
"console_project_uuid": "…",
"retention_minutes": 43200,
"metronome_customer_id": "<UUID>"
}
}
flowchart TD
SA["Service<br/>(client credentials)"] --> Direct["Direct API client"]
U1["User<br/>(username / password)"] --> Assistant["assistant client<br/>(assistant.eu.corti.app)"]
U2["User<br/>(own / company creds)"] --> Embedded["Embedded Assistant client"]
Console["Console embedded showcase<br/>(console.corti.app)"] -->|auto-creates studio_user_<UUID>| Studio["*-default_embedded_client"]
Direct --> T1["JWT<br/>(corti.* claims)"]
Assistant --> T2["JWT<br/>(corti.* claims)"]
Embedded --> T3["JWT<br/>(corti.* claims)"]
Studio --> T4["JWT<br/>(corti.* claims)"]
Several of the same logical claims (console_project_uuid, openmeter_customer_id,
stripe_customer_id, metronome_customer_id, retention_minutes, IsNFR)
appear on multiple clients — each client carries its own copy, and the
delivery type (Hardcoded claim vs User Attribute) varies per client.
2. Direct API client (client credentials)
Created in Corti Console via Create API client → Direct API access with the Client credentials authentication method.
In Keycloak this produces a confidential client with:
- Client authentication ON
- Only Service account roles enabled in the authentication flow (a pure client-credentials grant — there is no human user)
The client's dedicated scope carries 7 token mappers. Every one writes into
the corti.* namespace. Note the Type column — most are Hardcoded claim
(a fixed value baked onto the client), but two are User Attribute mappers:
| Mapper | Type | Token Claim Name | JSON type |
|---|---|---|---|
console_project_uuid |
Hardcoded claim | corti.console_project_uuid |
String |
stripe_customer_id |
Hardcoded claim | corti.stripe_customer_id |
String |
retention_minutes |
User Attribute (retention_minutes) |
corti.retention_minutes |
int |
console_project_id |
Hardcoded claim | corti.console_project_id |
String |
metronome_customer_id |
Hardcoded claim | corti.metronome_customer_id |
String |
IsNFR |
User Attribute | corti.isNFR |
String |
openmeter_customer_id |
Hardcoded claim | corti.openmeter_customer_id |
String |
The quirk worth flagging: even though a client-credentials client has no
human user, retention_minutes and IsNFR are User Attribute mappers — they
read from the client's service-account user attributes, while the rest are
hardcoded onto the client itself.
Default (auto-provisioned) client-credentials clients carry an extra
auto_created mapper. When a project's client is auto-created by the
platform (its dedicated scope name ends in default_client-dedicated), the
scope has an 8th mapper — auto_created (Hardcoded claim, corti.auto_created
= true, String) — that marks the client as system-provisioned rather than
user-created. A regular Direct API client created by a customer does not have
it. The studio showcase embedded client carries the same auto_created marker
(see §4).
3. Embedded Assistant clients
Created in Corti Console via Create API client → Embedded Assistant. A
human user authenticates directly against this client with their own (or a
company-provided) username and password — there is no brokered login into the
assistant client. It offers three authentication modes:
| Mode | Keycloak capability config |
|---|---|
| Auth code (with PKCE) | Public client, Standard flow only, Client authentication OFF |
| Auth code (without PKCE) | Frontend + backend; the client secret is held on the backend. Standard flow |
| ROPC (Resource Owner Password) | Direct access grants enabled, Client authentication OFF |
How the user logs in (per mode)
The modes differ mainly in where the user types their credentials and whether a backend secret is involved:
- Auth code (with PKCE) — the user is redirected to Corti's Keycloak-hosted
login page and authenticates there (never in the customer's app). The app
generates a PKCE
code_verifier/code_challenge, receives an authorizationcodeon redirect back, and exchangescode+ verifier for tokens. Public client, no secret — the PKCE verifier proves the exchange came from the same app. Best for SPAs / mobile / anything that can't safely hold a secret. This is the recommended mode. - Auth code (without PKCE) — the user logs in on the same Keycloak-hosted
login page (identical experience). The difference is the
code-for-token exchange is performed by a backend using the client secret, so this requires a frontend + backend (the secret must never reach the browser). Best for classic server-side web apps. - ROPC (Resource Owner Password) — no redirect. The user types their username/password directly into the customer's own application UI, which sends the raw credentials to Keycloak's token endpoint and gets tokens back in one call. Discouraged: the app sees the plaintext password and it can't do SSO/MFA/identity-provider login. Use only for legacy/constrained integrations.
| Mode | Where the user enters credentials | Secret? | Needs a backend? |
|---|---|---|---|
| Auth code + PKCE | Keycloak hosted login page (redirect) | No | No |
| Auth code (no PKCE) | Keycloak hosted login page (redirect) | Yes (on backend) | Yes |
| ROPC | The customer's own app UI (no redirect) | No | No |
Only the redirect-based modes (the two auth-code flows) can support bring-your-own-SSO: the Keycloak login page can hand off to a customer's external identity provider. ROPC cannot, because there is no browser redirect for the IdP to participate in.
Because a real user logs in, the User Attribute mappers on this client resolve against that user's attributes — which always come from the user's customer group membership (see §6). A user with no customer group gets empty values for those claims.
4. Studio showcase embedded client (*-default_embedded_client)
Console (console.corti.app) hosts an embedded showcase to demo the embedded
Assistant feature. In the showcase the user has not provided their own
credentials yet, so Console auto-creates a user named
studio_user_<UUID>@users.corti.app and logs that user into a per-project client
named <project>-default_embedded_client.
That client carries 8 token mappers (note the two studio-specific ones,
is_studio_client and auto_created):
| Mapper | Type |
|---|---|
console_project_uuid |
Hardcoded claim |
is_studio_client |
Hardcoded claim |
openmeter_customer_id |
Hardcoded claim |
stripe_customer_id |
Hardcoded claim |
auto_created |
Hardcoded claim |
metronome_customer_id |
Hardcoded claim |
retention_minutes |
User Attribute |
IsNFR |
User Attribute |
As with every other client, the claims land under the corti.* namespace.
The retention_minutes / IsNFR claims are absent for the studio user.
These values are sourced from customer group attributes (see
§6), and the auto-created
studio_user_<UUID> has no attributes of its own and is not a member of any
group — so there is nothing for these User Attribute mappers to emit.
5. The assistant client (assistant.eu.corti.app)
The standard Assistant. A human user logs in with a username and password
directly against the assistant client. Its assistant-dedicated dedicated
scope contains 3 User Attribute mappers:
| Mapper | Category | Type |
|---|---|---|
Metronome |
Token mapper | User Attribute |
IsNFR |
Token mapper | User Attribute |
Retention Minutes |
Token mapper | User Attribute |
These resolve against the logged-in user's attributes, but the source differs:
Metronomereads the user-levelMetronome Customer IDattribute set directly on the user — so it works even for users who are not in any group/customer.IsNFRandRetention Minutesread customer group attributes (is_nfr/retention_minutes) inherited via group membership. These claims are optional — they are only emitted when the group sets the attribute, andis_nfris only set whentrue(see the conditions in §6).
6. The customer feature (groups → user attributes)
The User Attribute mappers above need somewhere to read their values from. That source is the customer feature.
A customer is a Keycloak group (e.g. platform-api/<project>/<customer>)
that holds multiple users. The group carries attributes that every user in the
group inherits. One example group's attributes:
| Group attribute | Example value |
|---|---|
console_project_id |
<project> |
is_nfr |
true |
console_distributor_id |
corti |
console_customer_id |
<customer> |
The retention_minutes and IsNFR User Attribute mappers read from these
inherited group attributes (retention_minutes / is_nfr) — they are not
set directly on the user.
Because these are User Attribute mappers, the corti.retention_minutes /
corti.isNFR claims are only present when the backing attribute actually has
a value. In practice that means all of the following must hold:
- A real user is logging in (an embedded / assistant user) — not a service-account client-credentials token.
- That user is a member of a group that sets the attribute.
- For
is_nfrspecifically: the attribute is set only whentrue. For a non-NFR user we don't set the group attribute at all, socorti.isNFRis absent from the token entirely (it is not emitted asfalse).
Consumers must therefore treat these claims as optional — a missing
corti.isNFR means "not NFR", and a missing corti.retention_minutes means no
group-configured retention. This is why the studio user (no group) gets neither
(see §4).
metronome_customer_id is the exception: it is a user-level attribute
(Metronome Customer ID on the User), not a group attribute — see
§7.
7. Special case — metronome_customer_id
The Metronome customer id is delivered differently depending on the client.
The only client that reads it from the user is the assistant client; every
other client carries it as a hardcoded claim, so whatever value is baked onto
that client is what shows up in the token's corti.metronome_customer_id.
On the User entity there is a Metronome Customer ID attribute (e.g. user
ffa+customer@corti.ai → <some-uuid>).
| Client | How metronome_customer_id is delivered |
|---|---|
assistant client (a real user logs in directly) |
User attribute — the Metronome user-attribute mapper reads the user's Metronome Customer ID, so the claim follows the logged-in user |
| Embedded Assistant clients | Hardcoded claim — baked onto each (separate) embedded client; that fixed value is what lands in corti.metronome_customer_id |
Studio showcase embedded client (*-default_embedded_client) |
Hardcoded claim — same as above (see §4) |
| Direct API client (client credentials) | Hardcoded claim — baked onto the client (the metronome_customer_id mapper in §2) |
In other words, metronome_customer_id is a User Attribute mapper only on
the assistant client; on every other client it is a Hardcoded claim fixed at
client-creation time.
8. Known messiness
This area grew organically. These four problems are the case for the Organizations migration — each is listed with how the target model resolves it. See Organization Migration for the rollout.
-
Naming is inconsistent.
console_project_idvsconsole_project_uuid;isNFR(mapper) vsIsNFR(display) vsis_nfr(group/user attribute key);retention_minutesvs "Retention Minutes";Metronome(mapper) vsmetronome_customer_id(claim). → Organization attribute keys are snake_case and defined once per organization, so there is no mapper display name to drift from the claim name.console_project_uuidis dropped outright. -
The same claim is delivered two different ways (Hardcoded claim on client-credentials clients vs User Attribute on user-based clients). This is a drift risk — it's exactly what the audit script exists to catch. → Both client families become organization members (service accounts via their project's hidden organization), so one mechanism serves both and the dual mapping disappears.
-
Every client redefines the same recurring claims.
console_project_uuid,openmeter_customer_id,stripe_customer_id,metronome_customer_id,retention_minutes, andIsNFRare copied onto each client's dedicated scope independently, so they can drift between clients. → Values live once on the organization and are emitted by Keycloak's own organization mappers. Only genuinely client-scoped values (a per-client retention override,auto_created,is_studio_client) stay on the client, andstripe_customer_id/openmeter_customer_idare dropped. -
API-driven creation with no single source of truth. The mapper set each client/customer receives is defined in the Console /
platform-service/platform-apicode paths that call the Keycloak Admin API, not in versioned declarative config. → Partly resolved: the shared claims collapse onto one organization record, so there is nothing per-client left to drift, and theopenmeter_customer_idaudit script becomes unnecessary. Organization provisioning is still Admin-API-driven, so this problem is reduced rather than eliminated.