Documentation navigation
Token Consumers
This is a map of who reads a Corti access token, which part of the token they
read, and how — across the whole corticph GitHub estate. It is the
consumer-side counterpart to Token Mappers (which client
gets which mapper, i.e. how the token is produced) and the
Platform Mappers Contract (the claim
contract and the target organization model).
Use it for impact analysis: before a claim is renamed, moved, or dropped —
and the Organizations migration does all three, moving project identity to
organization.<org> and narrowing corti to client-level attributes (see
the contract and
Organization Migration) — this tells you
exactly which repos and which lines break.
Verified against the live corticph org via gh search code on 2026-06-10,
building on the research in
corticph/platform-api#283
(PLA-5670). It is a point-in-time snapshot — file paths and struct tags
drift; re-verify before relying on a specific line.
1. Two client families, two ways to carry identity
A token's contents depend on which client family minted it. This is the single most important thing to know when reading the tables below — the same consumer behaves differently depending on the token it receives.
CC clients (client_credentials) |
Delegated clients (assistant, copilot-frontend) |
|
|---|---|---|
| Created by | platform-api CreateClient |
platform-service realm bootstrap or platform-api CreateClient |
corti.console_project_id? |
✅ baked by a hardcoded-claim mapper | ❌ no mapper at all |
| How project identity reaches the consumer | the corti.console_project_id claim |
the groups claim path /platform-api/{projectID}/{customerID}, parsed by the consumer |
Delegated clients most commonly back the Embedded Assistant flow, but that is not their only use — they can equally be configured for direct API access. The token mechanics in the table above apply regardless of which way the delegated client is used.
See Token Mappers for the producer-side mechanics.
The Organizations migration removes this asymmetry. Both client families
become organization members — delegated users through their customer
organization, service accounts through their project's hidden organization — so
both carry organization.<org>.console_project_id. The groups-path fallback
that exists only to paper over the gap becomes dead code. See
Organization Migration.
2. Master matrix — token field → consumers
Every token field, the repos that read it, and what they do with it.
| Token field | Consumed by | How / for what |
|---|---|---|
iss |
corti-core-api, authorization-service, traefik jwt-service-router, copilot-api, code-frontend, Assistant (frontend) |
split on realms/ → realm/tenant; drives JWKS lookup and tenant routing. Assistant uses it heavily — token recognition, realm/authority mapping, refresh + logout endpoints, log context, embedded re-auth |
aud |
corti-core-api (verified), platform-api (verified) | code-api does not verify it (verify_aud: False) |
scope |
authorization-service (Casbin authz), traefik deny-jwt-scope, copilot-api (feature gating) |
deny-jwt-scope is a deny-list and fails open on a missing/malformed token |
realm_access.roles |
authorization-service, corti-core-api, code-api, copilot-api, platform-api, common-service-library, Assistant (frontend) | RBAC. Assistant populates session/JWT roles (incl. after refresh) and tRPC session roles |
resource_access |
corti-core-api (realm-management.manage-users → superuser), copilot-api, agent-api, common-service-library |
per-resource roles |
groups |
copilot-api, gocomo/maestro, platform-api, platform-service, corti-api-console, Assistant (frontend) | parse /platform-api/{projectID}/{customerID} for project identity. Not in the shared lib, not read by corti-core-api / authorization-service. Assistant derives consoleProjectId from this path on delegated tokens, and also uses groups for session groups, group-specific legacy templates, GrowthBook flags, and analytics identity (Mixpanel/PostHog) — see §3 |
azp |
authorization-service (realm-role gate), gocomo, common-service-library, Assistant (frontend) | client id. Assistant uses it as the client_id for refresh-token calls (falls back to AUTH_KEYCLOAK_ID) and on the session |
sub |
corti-core-api, copilot-api, agent-api, voice-vault, Assistant (frontend) | user id / attribution. Assistant requires it for token recognition, refresh/session user id, log context, and embedded user identity |
preferred_username |
code-api, code-frontend, copilot-api, common-service-library | display / identity |
corti.* |
copilot-api, agent-api, gocomo/maestro, audio-bridge | business claims — see §3 |
3. corti.* business-claim consumers
The custom business claims (console_project_id, the billing-provider IDs,
is_nfr, retention_minutes, console_distributor_id) are read by four
backends — though console_distributor_id has no token consumer today (see
the note below). The shared claims struct does not include them, so each
consumer defines its own struct. The
table also lists Assistant for completeness: it consumes no corti.* claims
but is a load-bearing consumer of project identity, deriving consoleProjectId
from the groups path on delegated tokens.
| Repo | File(s) | Claims read (tag/access) | Project identity | Notes |
|---|---|---|---|---|
| copilot-api | pkg/auth/auth.go, pkg/auth/console_ids.go |
console_project_id, metronome_customer_id, retention_minutes (*int, not a string); stripe_id decoded but never read |
claim + groups fallback |
parses the group path when the claim is absent; works for both CC and delegated tokens. RetentionMinutes() (pkg/auth/auth.go:105) drives the document retention policy — read at pkg/documentation/router.go:371, pkg/new_document/controller.go:80, pkg/interaction/router.go:109 |
| gocomo/maestro — a shared library, not a service | maestro/token.go, maestro/model.go |
console_project_id, metronome_customer_id, is_nfr (json:"is_nfr,string,omitempty") |
claim + first platform-api/ group path |
billing → Metronome. BillingContextMiddleware is mounted by copilot-api, audio-bridge, agent-api (and corti-api-console), so those services parse the token twice — once here, once in their own auth package. Emits the groups claim as a billing dimension |
| agent-api | pkg/auth/context.go |
console_project_id; stripe_id decoded but never read |
claim only — no groups fallback |
ProjectID() returns ErrMissingConsoleProjectID when the claim is absent, so it relies on a CC token carrying console_project_id |
| audio-bridge | internal/server/auth.go |
console_project_id (runtime map access), metronome_customer_id (logged) |
claim, falls back to a default project id | non-structured map access |
| code-frontend | — | iss only |
n/a | decodes the issuer for env/tenant routing; not a corti.* consumer |
| Assistant (frontend) | packages/flags/src/getGrowthbookAttributes.ts (extractConsoleProjectAndCustomer) |
none — does not read corti.* today |
groups path only |
parses /platform-api/{projectID}/{customerID} from the groups claim on delegated tokens to derive consoleProjectId; a load-bearing consumer of project identity via groups, not corti.* |
corti.console_distributor_id — produced but not consumed from the token.
Console manages the distributor ID per project (super-admin panel; stored in
Console's DB and pushed to the bound group/org attributes by platform-api).
It is emitted onto tokens by the org mapper as corti.console_distributor_id
(see Platform Mappers Contract and
Token Mappers), but no backend or frontend reads it
from the token today — the only struct references (platform-api
models/user.go, models/group.go) are management-side request/response
models, not token parsing. It feeds activation and finance reporting
through analytics exports sourced from Console's database, not the JWT. Listed
here so the field is documented alongside the other business claims; a future
consumer that needs distributor identity at request time would read this claim.
Assistant — full token surface. Assistant (corticph/assistant, next)
is a broad consumer of the standard claims even though it reads no
corti.*. Per a current-snapshot audit
(deployments#19534):
iss— recognise Keycloak tokens, map authority/realm, build refresh & logout URLs, log context, embedded re-auth, legacy-template env (packages/auth/src/decodeToken.ts,packages/auth/src/auth.ts,packages/auth/src/server.ts,packages/api/src/trpc.ts,apps/web/src/utils/embeddedAPI/handlers/useHandleAuth.ts).sub— token recognition, refresh/session user id, log context, embedded user identity.realm_access.roles— session/JWTroles(incl. after refresh), tRPC roles.groups— session groups,consoleProjectId/customer extraction (packages/flags/src/getGrowthbookAttributes.ts), group-specific legacy templates, GrowthBook flags, analytics identity (Mixpanel/PostHog), embedded same-user check.azp—client_idfor refresh-token calls (falls back toAUTH_KEYCLOAK_ID) and on the session.- Not used:
aud,scope,resource_access,preferred_username,corti.*.
Two struct tags diverge from the produced claim names: agent-api and copilot-api
both decode stripe into json:"stripe_id", while platform-api bakes the claim
as corti.stripe_customer_id. gocomo decodes is_nfr with json:"is_nfr,string"
(string-to-bool). These are stated here as facts for impact analysis, not as a
to-do list.
Two consequences for the Organizations migration.
stripe_customer_idis safe to drop. Bothstripe_idfields (copilot-api/pkg/auth/auth.go,agent-api/pkg/auth/context.go) are declared and never read — the only other references are"test-stripe-id"fixtures. Removing the claim needs no behavioural change, only deleting the dead fields.- Two consumers fail at decoding, not merely at reading the wrong location.
Both need a type change, not just a path change:
- gocomo's
json:"is_nfr,string". Organization attributes are arrays, so a string-to-bool tag cannot decode["false"]. Needs a custom unmarshal. - copilot-api's
RetentionMinutes *int.corti.retention_minutesis emitted as a JSON int today (platform-api sets"jsonType.label": "int"ininternal/infra/repositories/keycloak_realm_repo.go), and copilot-api's own test asserts that a string value fails with "cannot unmarshal string into Go struct field CortiAttrs.corti.retention_minutes of type int". Organization attributes are string arrays, so the organization half requires a custom unmarshal regardless; whether the surviving client-levelcorti.retention_minutesstays an int or becomes a string is still open — see Platform Mappers Contract §5.
- gocomo's
4. Signature verification
The token is signed by Keycloak (RS256). Two downstream patterns exist: verify locally against the realm's JWKS, or trust the gateway and parse the payload without verifying.
| Repo | Pattern |
|---|---|
| corti-core-api | verify locally via JWKS (core/lib/jwtvalidator; checks aud + iss) |
| code-api | verify locally via Python PyJWKClient, RS256 — skips aud and selects the realm from an X-Customer header, not iss |
| platform-api | verify locally via auth0 go-jwt-middleware + caching JWKS provider |
| authorization-service | 2-pass: ParseUnverified to read iss → ParseWithClaims + keyFunc (full verify), then Casbin authz |
| copilot-api / agent-api / gocomo-maestro (library) / call-service / voice-vault | ParseUnverified — trust the gateway, decode claims only (voice-vault uses a nil keyfunc and ignores ErrTokenUnverifiable) |
traefik-plugins (deny-jwt-scope, jwt-service-router) |
base64-decode the payload only — no signature check; deny-jwt-scope fails open |
Where the cryptographic verification actually happens is the gateway, via
Envoy SecurityPolicy JWT with remoteJWKS (RS256) — defined in the
corticph/charts repo
(charts/envoy-gateway/templates/ai-gateway-proxy/securityPolicyAuth.yaml,
charts/inference-service/templates/securityPolicy.yaml), not in
traefik-plugins. Services that ParseUnverified are safe only because they sit
behind that gateway.
5. The shared claims structs
There are two shared Go token parsers, and they do not overlap.
corticph/common-service-library package jwtclaims defines the canonical
Default struct (golang-jwt v5), used by corti-core-api, call-service, and
others. Fields:
Azp, Acr, AllowedOrigins, RealmAccess.Roles, ResourceAccess, Scope,
Sid, Email, EmailVerified, PreferredUsername, GivenName, FamilyName,
Realm — plus the embedded RegisteredClaims (iss / aud / sub / exp).
It has no groups field and no corti.* fields — which is why the business
claims appear in §3 rather than here.
corticph/gocomo/maestro is the other one, and it does parse the business
claims — BillingContext embeds RegisteredClaims plus azp, client_id,
email, groups, and a nested Corti struct. Because it ships as HTTP
middleware (BillingContextMiddleware), copilot-api, audio-bridge and agent-api
each parse the same token twice: once through maestro for billing, once
through their own pkg/auth struct for request handling. Consolidating those two
parsers is the obvious follow-up, and is why maestro is sequenced first in the
migration workstreams.
6. Non-consumers
These repos surface in a code search for token fields but are not runtime consumers — they match only via vendored OpenAPI specs, generated SDK clients, indirect dependencies, debug scripts, or because they produce tokens:
api-specs— spec definitions onlyassistant-ios— generated SDK, mirrors the specml-service-llm-reasoning— extractsconsole_project_idfor Opik trace metadata only (observability, not functional)local-pilot— debugjqscripts that print claims for local testingagent-memory— JWT is an indirect dependency
7. Using this when changing the token
When a claim is about to be renamed, moved, or removed:
- Find the readers — locate the claim in §2
/ §3, then confirm in the repos with
gh search code '<claim>' --owner corticph. - Check the struct tag — the consumer's JSON tag must match the produced
claim name exactly (see the §3 note on
stripe_idandis_nfr,string). - Confirm CC vs delegated presence — a claim present on CC tokens may be
absent on delegated tokens (notably
corti.console_project_id); a consumer without agroupsfallback only works for the family that carries the claim. - Mind the gateway — verification lives in the Envoy
SecurityPolicyin thechartsrepo, and the traefikdeny-jwt-scopeplugin fails open; changes toiss/scopeaffect routing and the deny-list, not just business logic. - Expect arrays, not scalars — anything moving to
organization.<org>becomes an array (idexcepted), and unset values arrive as the literal string"null"rather than a missing key. A consumer that only changes the claim path and keeps a scalar type will fail to decode. See the parsing contract in Platform Mappers Contract §6.