Back to all guides
Agent Gateway

IndyKite Token Service: mint delegation tokens for agent chains

Run the self-hosted IndyKite Token Service (ITS): an RFC 8693 token exchange and RFC 7662 introspection service that mints the delegation token the Agent Gateway forwards in X-IK-Token, so policies can see the whole chain of agents acting for a user.

What is the IndyKite Token Service?

The IndyKite Token Service (ITS) is a small, self-hosted OAuth 2.0 service that issues delegation tokens. It implements the RFC 8693 token exchange grant and the RFC 7662 introspection endpoint, publishes its keys over OpenID Connect discovery, and audits every request. It ships as the indykite/token-service Docker image and runs next to your Agent Gateway (IAG) instances, in your own infrastructure.

Its job is to make a multi-agent delegation chain visible and verifiable. When an IAG is configured to use ITS, the user's access token travels untouched in Authorization, and a token minted by ITS travels beside it in the X-IK-Token header. That token carries the user as sub and the agents that acted for the user as a nested act claim. Each gateway on the path exchanges the incoming delegation token for a new one that adds itself to the chain, so by the time a request reaches the IndyKite platform, the X-IK-Token is the complete, signed history of who acted for whom. The platform validates it and exposes its claims to KBAC and ContX IQ policies as $ik_token.

When do I need it?

Without ITS (default IAG mode) With ITS (Token Service mode)
The gateway exchanges tokens at your identity provider, and the delegated token replaces the caller's token in Authorization. ITS mints the delegated token; the caller's token stays in Authorization and the delegation travels in X-IK-Token.
The delegation chain is whatever your identity provider puts into its exchanged token. The chain is built by ITS, hop by hop: each gateway introspects the incoming X-IK-Token and uses it as the subject of its own exchange.
Policies see only the user token ($token). Policies see both: $token for the user and $ik_token for the delegation chain, e.g. $ik_token.act.act.sub.

Choose Token Service mode when a policy has to reason about which agents handled a request, when your identity provider's exchange cannot express a nested chain, or when you want every delegation minted and refused to appear in an audit trail you control.

The most common trigger is simpler: the Agent Gateway needs an identity provider that implements RFC 8693 token exchange, and many do not. With the token_service section configured, the gateway exchanges at ITS instead, and the identity provider only has to introspect the user's token and issue the agents' client-credentials tokens. ITS never authenticates the original caller itself; it exchanges and validates tokens issued elsewhere and layers the delegation chain on top. Being standard OAuth 2.0 with discovery, any compliant client can use it, not only the gateway.

Which endpoints does it expose?

All paths are relative to the configured service.base_url, which is also the iss claim of every token it signs.

Endpoint Purpose
POST /oauth2/tokenThe token exchange. Client authentication required.
POST /oauth2/introspectDescribes a token ITS issued. Client authentication required.
GET /.well-known/openid-configuration
GET /.well-known/oauth-authorization-server
Discovery document: issuer, token_endpoint, introspection_endpoint, jwks_uri, userinfo_endpoint, grant_types_supported (only the token-exchange grant), id_token_signing_alg_values_supported, token_endpoint_auth_methods_supported and introspection_endpoint_auth_methods_supported (both client_secret_basic). Cacheable for 5 minutes.
GET /.well-known/jwks.jsonThe public half of every configured signing key, so a relying party can verify what ITS issued. Never contains private material.
GET|POST /userinfoReturns the claims of an ITS-issued access token presented as Authorization: Bearer, minus the token mechanics (iss, aud, exp, iat, nbf, jti). Tokens of any other issuer are refused with 401 invalid_token.
GET /oauth2/auth, POST /oauth2/revokeAdvertised in discovery for completeness, but answer 501 with {"error": "not_implemented"}: tokens are obtained only by exchange.
GET /healthz, /readyz, /startupzHealth probes, served on a separate port (9080 by default, service.healthcheck_port).

How do I exchange tokens?

The exchange takes a subject token (the user, or the delegation token of the previous hop) and an actor token (the agent about to act), both required, and returns a short-lived token about the same subject with the actor added to the chain. The caller authenticates with HTTP Basic using the idp.client_auth credentials.

curl -u "$ITS_CLIENT_ID:$ITS_CLIENT_SECRET" -X POST <ITS_BASE_URL>/oauth2/token \
-d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
-d "subject_token=$USER_OR_DELEGATION_JWT" \
-d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
-d "actor_token=$AGENT_JWT" \
-d "actor_token_type=urn:ietf:params:oauth:token-type:access_token" \
-d "requested_token_type=urn:ietf:params:oauth:token-type:access_token"
Parameter Rule
grant_typeMust be urn:ietf:params:oauth:grant-type:token-exchange.
subject_token, subject_token_typeRequired. The type must be urn:ietf:params:oauth:token-type:access_token. The token must match one of the token_introspection configurations for subject_token, or be an ITS-issued token.
actor_token, actor_token_typeRequired - ITS only issues delegated tokens. Same type rule; must match a configuration for actor_token.
requested_token_typeOptional. Accepted values: urn:ietf:params:oauth:token-type:access_token or urn:ietf:params:oauth:token-type:jwt.
audience, resourceOptional, repeatable. Every value must be listed in idp.audiences / idp.resources; anything else is refused with invalid_target. Both end up in the issued token's aud.
scopeAccepted and ignored: the claims of an issued token are fixed.

Response (200, Cache-Control: no-store):

{
  "access_token": "<signed JWT>",
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
  "token_type": "Bearer",
  "expires_in": 300
}

The issued token carries exactly these claims. The act claim nests the subject token's own act inside the new actor, so the oldest agent ends up innermost:

{
  "iss": "https://its.example.com",
  "sub": "millicent",
  "aud": [
    "indykiteagent-2"
  ],
  "iat": 1790000000,
  "exp": 1790000300,
  "jti": "<jti>",
  "act": {
    "sub": "indykiteagent-2",
    "type": "Agent",
    "act": {
      "sub": "indykiteagent",
      "type": "Agent"
    }
  }
}

How aud is decided: the request's audience values if any; otherwise the subject token's own aud, so a delegation never widens what it was issued for; otherwise (an opaque subject token names no audience) every value in idp.audiences. Requested resource values are appended. In every case each value must be configured, or the exchange is refused.

Exchange errors

Errors follow RFC 6749: a JSON body with error and error_description.

Status / error Meaning
401 invalid_clientthe client credentials are missing or wrong. Comes with a WWW-Authenticate: Basic challenge.
400 unsupported_grant_typegrant_type is not the token-exchange grant.
400 invalid_requestA required parameter is missing (subject_token is required, actor_token is required, this service only issues delegated tokens, ...), a token type is not the access-token URN, or requested_token_type is unsupported.
400 invalid_grantthe subject_token is not valid or the actor_token is not valid: no configuration matches the token, or it is expired, badly signed, or otherwise inactive. Also the <name> has no subject and the subject_token has a malformed act claim.
400 invalid_targetthis service does not issue tokens for the audience "<value>" (or resource): the value is not configured.
500 server_errorthe tokens could not be validated (an introspection provider could not be reached) or the token could not be issued (signing failed). Audited as ERROR, not as a refusal.

Every response of /oauth2/token and /oauth2/introspect, success or error, carries Cache-Control: no-store and Pragma: no-cache.

How do I introspect a token?

ITS keeps no record of the tokens it signs; it answers by re-validating the token it is handed. Only its own tokens are described. Anything else - another issuer's token, an opaque token, an expired one - is answered as inactive and nothing more.

curl -u "$ITS_CLIENT_ID:$ITS_CLIENT_SECRET" -X POST <ITS_BASE_URL>/oauth2/introspect \
-d "token=$DELEGATION_JWT"
{
  "active": true,
  "token_type": "Bearer",
  "sub": "millicent",
  "iss": "https://its.example.com",
  "aud": [
    "indykiteagent-2"
  ],
  "jti": "<jti>",
  "iat": 1790000000,
  "exp": 1790000300,
  "act": {
    "sub": "indykiteagent-2",
    "type": "Agent",
    "act": {
      "sub": "indykiteagent",
      "type": "Agent"
    }
  }
}

An unusable token answers {"active": false} with status 200. token_type_hint is accepted and ignored. A request with no token is refused with 400 invalid_request and token is required; missing client credentials give 401 invalid_client.

How do I configure it?

ITS reads one YAML file (--config=<file>) or a directory (--config-dir=<dir>). With a directory, every .yaml, .yml, .json or .toml file in it is merged in alphabetical order, later files overriding earlier ones and maps merging key by key. That is how secrets stay out of the main file: a second file, written by your secret store, can name a configuration and supply only its client_auth block. Subdirectories are not read, and the service refuses to start on an empty directory. The two flags are mutually exclusive.

service:
  name: token-service
  port: 8102
  log_level: info
  # healthcheck_port: 9080
  # Public HTTPS URL of this service. REQUIRED: it is the iss claim of every token it signs.
  base_url: https://its.example.com
idp:
  # Every audience a token may be issued for. REQUIRED, at least one.
  audiences:
    - indykiteagent
    - indykiteagent-2
  # resources:
  #   - https://api.example.com/orders
  token_ttl: 5m          # REQUIRED; keep it short, a delegation token cannot be revoked
  actor_type: Agent      # written into act.type; defaults to Agent
  client_auth:           # REQUIRED; what callers (the gateways) present as HTTP Basic
    type: client_secret_basic
    client_id: agent-gateway
    client_secret: "<generate one>"
  signing_keys:          # REQUIRED; private JWKs with alg, kid and use set. First key signs.
    - '{"kty":"RSA","alg":"RS256","kid":"its-2026-q3","use":"sig","n":"...","e":"AQAB","d":"...","p":"...","q":"...","dp":"...","dq":"...","qi":"..."}'
token_introspection:
  configurations:
    # Offline: verify user tokens of your IdP against its JWKS.
    corporate-idp:
      matcher:
        type: jwt
        issuer: https://idp.example.com
        audience: my-app
      token_types: [subject_token]
      validation:
        offline:
          jwks_uri: https://idp.example.com/.well-known/jwks.json
          cache_ttl: 1h
    # Offline: verify the agents' client-credentials tokens, one configuration per audience.
    agent-orchestrator:
      matcher:
        type: jwt
        issuer: https://idp.example.com
        audience: indykiteagent
      token_types: [actor_token]
      validation:
        offline:
          jwks_uri: https://idp.example.com/.well-known/jwks.json
    # Online fallback for opaque user tokens.
    opaque-users:
      matcher:
        type: opaque
      token_types: [subject_token]
      validation:
        online_oidc:
          userinfo_endpoint: https://idp.example.com/userinfo
          cache_ttl: 5m
audit:
  delivery: webhook
  http:
    url: https://audit.example.com/records
    method: POST
    auth:
      type: api-key
      api_key: "<key>"
      api_key_header: X-API-Key
  async:
    buffer_size: 100

service

Field Description
base_urlRequired. The public HTTPS URL of the service and the iss of every token. It must stay stable, and it must be https: the IndyKite platform validates a JWT offline only for an https issuer. Give the service its own host rather than a path.
port, name, environment, log_levelStandard service settings. port defaults to 8080.
healthcheck_portPort of /healthz, /readyz and /startupz; defaults to 9080, which the image's HEALTHCHECK probes.

Rules for base_url. It is an identifier first: it does not have to be reachable by ITS's own callers, but relying parties are configured with it, so it must never change once tokens are in circulation. The service refuses to start unless it is an absolute URL with a scheme and a host, using http or https, with no query and no fragment (service.base_url is required, must be an absolute URL with a scheme and a host, must not contain a query or a fragment). An http value is accepted with a startup warning outside the local and testing environments, but the IndyKite platform validates a JWT offline only for an https issuer, so use https in every real deployment. Prefer a host or subdomain of its own over a path: for an issuer with a path, RFC 8414 section 3.1 sends strict clients to /.well-known/oauth-authorization-server/<path>, which ITS does not serve. environment takes prod, stg, rc, dev, local, or testing and defaults to prod.

idp - ITS as an issuer

Field Description
audiencesRequired, at least one. The allow-list of aud values ITS may issue for. List every audience a subject token can carry (the user-facing client and every agent), otherwise an exchange is refused with invalid_target.
resourcesOptional allow-list for the resource exchange parameter. Without it no request may name a resource.
token_ttlRequired, positive. Lifetime of an exchanged token. Nothing can revoke a delegation token before it expires, so keep it as short as the workflow allows.
actor_typeThe type written into each act entry. Defaults to Agent; the platform uses it to resolve the actor in the graph.
client_authRequired. type (only client_secret_basic today), client_id, client_secret. The same pair goes into the gateway's token_service.client_auth.
signing_keysRequired, at least one. JSON JWK strings including the private part, each with alg (RS256/384/512, PS256/384/512, ES256/384/512 or EdDSA; symmetric algorithms are rejected), use: sig, and a unique kid (derived from the thumbprint when omitted). The first key signs; the others stay published in the JWKS so earlier tokens still verify. To rotate, add the new key at the top and drop the old one after its last token has expired.

token_introspection - which tokens ITS accepts

configurations is a map keyed by configuration name (lower-cased, unique, and used in startup errors and audit records). Each entry says how to recognise a token and how to validate it. Tokens ITS issued itself are verified against idp.signing_keys before any configuration is consulted and need none.

Field Description
matcher.typejwt matches a well-formed JWT by issuer + audience; opaque matches non-JWT tokens and is the fallback for a JWT no jwt entry matches. At most one opaque entry per token type, and no two jwt entries may share issuer + audience for the same token type; the service refuses to start otherwise.
token_typesAny subset of subject_token and actor_token. One entry can serve both, or the two can be validated differently.
validation.offlineVerify signature and exp/nbf locally. Either inline keys (public JWK strings) or a jwks_uri; with neither, the keys are discovered from the issuer's .well-known/openid-configuration. cache_ttl for fetched keys defaults to 1h. Only offline validation turns issuer and audience into a guarantee, because the signature covers them.
validation.online_oidcCall the issuer's userinfo_endpoint (required for opaque tokens, discoverable for JWTs). A response served as application/jwt is trusted only after its signature is verified, so set jwks_uri for an opaque matcher. cache_ttl caches the result; 0 disables caching.
validation.online_oauth2Call an RFC 7662 introspection_endpoint, authenticating with client_auth (client_secret_basic with client_id and client_secret, required). Supply the client_auth block from a separate merged file to keep the secret out of the main configuration; the service refuses to start without it, naming the configuration.

Exactly one validation method per configuration.

The whole token_introspection section is optional. Without it, only tokens ITS issued itself can be validated, which is enough for introspection but not for an exchange, whose subject and actor tokens come from elsewhere. Rules the service enforces at startup, each with its own error naming the configuration:

  • No configuration may claim ITS's own issuer (service.base_url); those tokens are always verified against idp.signing_keys.
  • An opaque matcher must leave issuer and audience empty (opaque matcher must not set issuer or audience), cannot use offline validation (opaque matcher cannot use offline validation), and with online_oidc must name the userinfo_endpoint (opaque matcher requires online_oidc.userinfo_endpoint), since there is no issuer to discover it from.
  • At most one opaque configuration per token type (configurations "a" and "b" both define an opaque matcher for token type "subject_token"), and no two jwt configurations with the same issuer and audience for the same token type.

At runtime a token is matched in this order: an iss equal to service.base_url is verified with the signing keys; otherwise a well-formed JWT is matched by issuer and audience against the jwt configurations for its token type; if none matches, or the token is not a JWT at all, the opaque configuration for that token type is used, if one exists. Issuer and audience are read before validation, so they select a configuration rather than guarantee anything on their own; only offline validation, where the signature covers them, turns the match into a guarantee.

audit

Same shape as the Agent Gateway's audit section: delivery: webhook with http.url, http.method and http.auth (no-auth, api-key with api_key and api_key_header, or mTLS with certificate and key paths), optionally async.buffer_size to deliver in the background; or delivery: file with storage_path, format, rotation_strategy, rotation_interval, rotation_max_bytes. Without the section, auditing is off - rarely what you want for an issuer of delegation tokens. See the record format below.

How do I make the IndyKite platform trust its tokens?

The platform validates an X-IK-Token through the project's Token Introspect configurations, exactly as it validates the user's Bearer token. So ITS needs one configuration per audience its tokens can carry, matching its issuer offline with its public key. Create it once per project with a Service Account token:

curl -X POST <API_URL>/configs/v1/token-introspects \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $SERVICE_ACCOUNT_TOKEN" \
-d '{
      "name": "its-indykiteagent-2",
      "project_id": "gid-of-project",
      "jwt_matcher": {
        "issuer": "https://its.example.com",
        "audience": "indykiteagent-2"
      },
      "offline_validation": {
        "public_jwks": [
          "{\"kty\":\"RSA\",\"alg\":\"RS256\",\"kid\":\"its-2026-q3\",\"use\":\"sig\",\"n\":\"...\",\"e\":\"AQAB\"}"
        ]
      },
      "ikg_node_type": "Person",
      "perform_upsert": false
    }'
  • jwt_matcher.issuer is the ITS service.base_url, and it must be https: the platform treats an http issuer as opaque and skips offline validation.
  • offline_validation.public_jwks holds the public JWK only (kty, n, e, alg, kid, use), as a JSON string; up to 10 keys, which is how a rotation is prepared ahead of time. Left empty, the platform fetches the keys from the issuer's discovery document instead, which requires ITS to be reachable from the platform.
  • One configuration per audience value the delegation token can carry on that path. Allow a few minutes for a new configuration to propagate.

Once trusted, the delegation token is accepted on every AuthZEN and ContX IQ endpoint and by the MCP server, always beside the user's Bearer token and with the same sub. Policies read it as $ik_token; this KBAC filter, for example, allows the action only when the chain started at the orchestrator:

"condition": {
  "cypher": "MATCH (subject)-[:DRIVES]->(resource:Car)",
  "filter": {
    "operator": "=",
    "attribute": "$ik_token.act.act.sub",
    "value": "orchestrator"
  }
}

How does the Agent Gateway use it?

Add a token_service section to each gateway. The whole section is optional; once present, all six fields are required.

token_service:
  base_url: https://its.example.com
  exchange_endpoint: /oauth2/token
  introspect_endpoint: /oauth2/introspect
  client_auth:
    type: client_secret_basic
    client_id: agent-gateway
    client_secret: "<the idp.client_auth.client_secret of ITS>"

As environment variables: JARVIS_TOKEN_SERVICE_BASE_URL, JARVIS_TOKEN_SERVICE_EXCHANGE_ENDPOINT, JARVIS_TOKEN_SERVICE_INTROSPECT_ENDPOINT, JARVIS_TOKEN_SERVICE_CLIENT_AUTH_TYPE, JARVIS_TOKEN_SERVICE_CLIENT_AUTH_CLIENT_ID, JARVIS_TOKEN_SERVICE_CLIENT_AUTH_CLIENT_SECRET.

With the section in place a gateway, on every request:

  1. Introspects the caller's Authorization token at the identity provider, as before.
  2. If the request carries an X-IK-Token, validates it at the ITS introspection endpoint; that token, not the user token, becomes the subject_token of this hop's exchange.
  3. Obtains its actor token from the identity provider by client credentials, as before.
  4. Exchanges at ITS and forwards the request with Authorization untouched and the new delegation token in X-IK-Token.

Run every gateway on one path in the same mode. A gateway without the section ignores an incoming X-IK-Token, so a mixed path loses the chain at that hop. The identity_provider section stays required either way: it still introspects the user and issues the actor tokens. The iag-token-exchange reference application in the developer-hub repository runs this setup end to end; see the Agent Gateway tutorial for the rest of the gateway configuration.

What do the audit records look like?

Every exchange and every introspection is recorded, whether it succeeded or not. An exchange leaves one record per token it looked at (action TOKEN_INTROSPECT) plus one for the exchange itself (action TOKEN_EXCHANGE), tied together by traceID.

{
  "decision": "TOKEN_EXCHANGED",
  "reason": "token exchanged",
  "subject": "millicent",
  "actor": "indykiteagent-2",
  "action": "TOKEN_EXCHANGE",
  "service": "token-service",
  "timestamp": "2026-09-22T10:15:04Z",
  "traceID": "4bf92f3577b34da6a3ce929d0e0e4736",
  "tokenID": "<jti>",
  "actorsChain": [
    "indykiteagent",
    "indykiteagent-2"
  ]
}
Action Decision Meaning
TOKEN_EXCHANGETOKEN_EXCHANGEDA token was issued; tokenID is its jti.
TOKEN_EXCHANGEEXCHANGE_REFUSEDThe request was refused; reason is the error_description the caller received.
TOKEN_INTROSPECTINTROSPECTEDThe token is active and was described.
TOKEN_INTROSPECTINTROSPECTED_INACTIVEThe token cannot be used; reason names why (expired, other issuer, no configuration matches, ...). Not a refusal: the question was answered.
TOKEN_INTROSPECTINTROSPECTION_REFUSEDRefused before any token was looked at: unauthenticated caller or no token parameter.
eitherERRORITS or a provider it depends on failed. Counted apart from refusals so an outage never looks like a denial.

actorsChain lists the agents oldest first, and actor is the last of them. The claims of a token that did not verify are never recorded.

How do I run it?

docker run --rm -d --name token-service -p 8102:8102 \
-v $(pwd)/token-service.yaml:/app/.configs/token-service.yaml:ro \
indykite/token-service:1.0.0 --config=/app/.configs/token-service.yaml
  • Pin a concrete tag from Docker Hub rather than latest. The image is built for linux/amd64; on Apple Silicon add --platform linux/amd64.
  • Put it behind TLS on its own hostname: service.base_url must be the https URL the gateways and the platform see.
  • Probe readiness on the discovery endpoint rather than only on /healthz: a 200 from /.well-known/openid-configuration proves the configuration loaded and traffic is served.
  • Mount the file that holds the private signing key and the client secret read-only, and keep it out of version control.

Unlike the Agent Gateway, which runs once per protected agent, ITS is deployed once per environment and shared by every gateway instance and any other OAuth 2.0 client that needs delegation tokens. Service logs are JSON on standard output. Audit records use the same delivery methods and file formats as the gateway; only the service value differs.

Docker Compose

services:
  token-service:
    image: indykite/token-service:1.0.0
    ports:
      - "8102:8102"
    volumes:
      - ./token-service.yaml:/app/config.yaml:ro
      - ./audit:/app/audit   # only with audit.delivery: file
    command: ["--config=/app/config.yaml"]

The container runs as user 65532, so the mounted configuration must be readable and an audit directory writable by that UID. The configuration file can be named and mounted anywhere; the --config argument must point at it.

Kubernetes

  • Run a single-container Deployment of indykite/token-service behind a Service and an Ingress or load balancer terminating TLS on the host named in service.base_url. The platform, and any relying party that discovers keys dynamically, must be able to reach /.well-known/jwks.json there.
  • Keep idp.signing_keys, idp.client_auth, and every token_introspection client_auth block in a Secret, never in a ConfigMap or Helm values. Non-sensitive settings (service, audiences, matchers, endpoints) can live in a ConfigMap.
  • Land both in one directory with a projected volume that lists the ConfigMap and the Secret as sources under distinct file names, and start the service with --config-dir=<dir>; the loader merges the files key by key, so the Secret can complete a configuration the ConfigMap defines. Two ordinary volumes cannot share a mount path, and the loader ignores subdirectories, so mounting each source separately does not work. The service reads its configuration at startup only; restart the Deployment after publishing a new Secret or ConfigMap version.
  • Probe /startupz, /readyz, and /healthz on port 9080, and run with runAsUser: 65532.
  • With a NetworkPolicy, allow ingress from the gateways and egress to the identity providers named in token_introspection and to the audit webhook.

Key rotation

Because idp.signing_keys is ordered and only the first key signs, rotate in three stages so no relying party ever meets a signature from a key it has not yet published:

  1. Publish. Add the new key to the list below the current one and redeploy. It appears in /.well-known/jwks.json but signs nothing yet. Wait at least as long as relying parties cache the JWKS: the IndyKite platform caches a discovered key set for 3 hours and does not refetch it when it meets an unknown key id, so a token signed with an unpublished key is refused with an invalid signature until the cache expires. If the platform trusts ITS through offline_validation.public_jwks instead of discovery, add the new public key to that Token Introspect configuration now and allow it to propagate; it accepts up to 10 keys for this purpose.
  2. Promote. Move the new key to the top of the list and redeploy. New tokens are signed with it; the old key stays published, so tokens already issued still verify.
  3. Retire. Once every token signed with the old key has expired, which is at most idp.token_ttl after the promotion, remove the old key and redeploy again, and drop its public half from public_jwks where used.

Related