Set up a Kong Identity auth server for tiered AI budgets

TL;DR

Create a Kong Identity auth server with dynamic claims that read per-client labels, then create one client for each person or service that will call your AI Models, with the labels that determine its tier, cap, org pool, and group membership. An openid-connect AI Identity Provider can then project those claims onto request headers for budget enforcement.

Prerequisites

This is a Konnect tutorial and requires a Konnect personal access token.

  1. Create a new personal access token by opening the Konnect PAT page and selecting Generate Token.

  2. Export your token to an environment variable:

    export KONNECT_TOKEN='YOUR_KONNECT_PAT'
  3. Run the AI Gateway quickstart script to automatically provision a control plane and data plane in Kong Konnect, and configure your environment:

    curl -Ls https://get.konghq.com/ai | bash -s -- -k $KONNECT_TOKEN 

This sets up a AI Gateway control plane named ai-quickstart, provisions a local data plane, and prints out the following environment variables export:

export AI_GATEWAY_ID=your-gateway-id
export KONNECT_TOKEN=$KONNECT_TOKEN
export KONNECT_CONTROL_PLANE_NAME=ai-quickstart
export KONNECT_CONTROL_PLANE_URL=https://us.api.konghq.com
export KONNECT_PROXY_URL='http://localhost:8000'

Copy and paste these into your terminal to configure your session.

Overview

This guide sets up the identity side of tiered AI budget enforcement: a Kong Identity auth server that issues each caller a token carrying its tier, individual spend cap, shared org pool, and group membership as claims. In the next guide in the series, you will apply tiered AI budgets on an AI Model with Kong Identity, then read the claims as request headers to enforce the actual budgets.

A claim is a piece of data included in a token when it’s issued, for example a caller’s tier. A label is a key-value tag attached directly to a client (the application or service registered with the auth server that requests tokens) when it’s created. This guide defines four dynamic claims that each read one label off the requesting client at token-issue time, so one claim definition serves every client instead of needing a new claim per caller.

The following example clients illustrate the model:

  • Carol has only the default tier: 4x label, the common case.
  • Dave also has tier: 4x, but an additional cap: strict label caps his individual spend below the tier ceiling.
  • Erin and Frank share an orgUnit: live-balance label, pooling their spend against one shared budget.
  • Grace has a groups: suspended label, which blocks her from every model entirely.

AI Gateway reads the resulting claims purely as request headers and never sees whether they came from client labels here or, for example, real group membership in an IdP like Okta, so nothing downstream changes if you swap identity providers later.

Create an auth server in Kong Identity

Create an auth server using the /v1/auth-servers endpoint:

_response=$(curl -X POST "https://us.api.konghq.com/v1/auth-servers" \
     --no-progress-meter --fail-with-body  \
     -H "Authorization: Bearer $KONNECT_TOKEN"\
     -H "Content-Type: application/json" \
     --json '{
       "name": "Tiered AI Budgets",
       "audience": "tiered-ai-budgets",
       "description": "Auth server for tiered AI budget enforcement"
     }')

Export the env variables:

export AUTH_SERVER_ID=$(echo "$_response" | jq -r ".id")
export ISSUER_URL=$(echo "$_response" | jq -r ".issuer")

Configure the auth server with a scope

A client requests a token under an OAuth2 scope. Configure one now, so each client created later can be granted it and later token requests can pass scope=budgets-access, using the /v1/auth-servers/$AUTH_SERVER_ID/scopes endpoint:

SCOPE_ID=$(curl -X POST "https://us.api.konghq.com/v1/auth-servers/$AUTH_SERVER_ID/scopes" \
     --no-progress-meter --fail-with-body  \
     -H "Authorization: Bearer $KONNECT_TOKEN"\
     -H "Content-Type: application/json" \
     --json '{
       "name": "budgets-access",
       "description": "Scope for tiered AI budget clients",
       "default": true,
       "include_in_metadata": false,
       "enabled": true
     }' | jq -r ".id"
)

Configure dynamic claims

Each claim reads a label off the requesting client and falls back to a default when the label isn’t set. Create all four with the /v1/auth-servers/$AUTH_SERVER_ID/claims endpoint, one request per claim.

budget_tier reads the client’s tier label, defaulting to the baseline tier:

curl -X POST "https://us.api.konghq.com/v1/auth-servers/$AUTH_SERVER_ID/claims" \
     --no-progress-meter --fail-with-body  \
     -H "Authorization: Bearer $KONNECT_TOKEN"\
     -H "Content-Type: application/json" \
     --json '{
       "name": "budget_tier",
       "value": "${ .Client.Labels.tier | default \"baseline\" }",
       "include_in_token": true,
       "include_in_all_scopes": true,
       "include_in_scopes": [],
       "enabled": true
     }'

budget_cap reads the client’s cap label, defaulting to empty when no individual cap applies:

curl -X POST "https://us.api.konghq.com/v1/auth-servers/$AUTH_SERVER_ID/claims" \
     --no-progress-meter --fail-with-body  \
     -H "Authorization: Bearer $KONNECT_TOKEN"\
     -H "Content-Type: application/json" \
     --json '{
       "name": "budget_cap",
       "value": "${ .Client.Labels.cap | default \"\" }",
       "include_in_token": true,
       "include_in_all_scopes": true,
       "include_in_scopes": [],
       "enabled": true
     }'

budget_org reads the client’s orgUnit label, defaulting to unassigned:

curl -X POST "https://us.api.konghq.com/v1/auth-servers/$AUTH_SERVER_ID/claims" \
     --no-progress-meter --fail-with-body  \
     -H "Authorization: Bearer $KONNECT_TOKEN"\
     -H "Content-Type: application/json" \
     --json '{
       "name": "budget_org",
       "value": "${ .Client.Labels.orgUnit | default \"unassigned\" }",
       "include_in_token": true,
       "include_in_all_scopes": true,
       "include_in_scopes": [],
       "enabled": true
     }'

kong_groups reads the client’s groups label into a JSON array:

curl -X POST "https://us.api.konghq.com/v1/auth-servers/$AUTH_SERVER_ID/claims" \
     --no-progress-meter --fail-with-body  \
     -H "Authorization: Bearer $KONNECT_TOKEN"\
     -H "Content-Type: application/json" \
     --json '{
       "name": "kong_groups",
       "value": "${ .Client.Labels.groups | default \"\" | splitList \",\" | compact | toJson }",
       "include_in_token": true,
       "include_in_all_scopes": true,
       "include_in_scopes": [],
       "enabled": true
     }'

Each request returns the created claim. For example, creating budget_tier returns:

{
  "id": "8f16f156-2f83-4b76-8f00-df5301c46017",
  "name": "budget_tier",
  "value": "${ .Client.Labels.tier | default \"baseline\" }",
  "include_in_token": true,
  "include_in_all_scopes": true,
  "include_in_scopes": [],
  "enabled": true
}

Notes:

  • toJson is required here. splitList and compact turn the label’s raw string into a list, but a Go template renders a list as [suspended], unquoted and comma-free, which isn’t valid JSON. Without toJson, that non-JSON text gets treated as a literal string claim instead of an array, and consumer_groups_claim silently fails to bind anything to it.
  • A claim referencing a label the client doesn’t have at all is omitted from the token entirely, it doesn’t fall through to default. default only catches an empty value, not a missing label. This applies to every claim here, not just kong_groups.

Create a client for each persona

Create one client for each of the five personas introduced in the overview, using the /v1/auth-servers/$AUTH_SERVER_ID/clients endpoint. Each client’s labels drive the claims configured previously.

First, create a client for Carol:

_response=$(curl -X POST "https://us.api.konghq.com/v1/auth-servers/$AUTH_SERVER_ID/clients" \
     --no-progress-meter --fail-with-body  \
     -H "Authorization: Bearer $KONNECT_TOKEN"\
     -H "Content-Type: application/json" \
     --json '{
       "name": "Carol",
       "grant_types": [
         "client_credentials"
       ],
       "response_types": [
         "none"
       ],
       "allow_all_scopes": false,
       "allow_scopes": [
         "'$SCOPE_ID'"
       ],
       "labels": {
         "tier": "4x"
       }
     }')

Export the env variables:

export CAROL_CLIENT_SECRET=$(echo "$_response" | jq -r ".client_secret")
export CAROL_CLIENT_ID=$(echo "$_response" | jq -r ".id")

Repeat for the remaining four personas, changing only name and labels.

Create a client for Dave:

_response=$(curl -X POST "https://us.api.konghq.com/v1/auth-servers/$AUTH_SERVER_ID/clients" \
     --no-progress-meter --fail-with-body  \
     -H "Authorization: Bearer $KONNECT_TOKEN"\
     -H "Content-Type: application/json" \
     --json '{
       "name": "Dave",
       "grant_types": [
         "client_credentials"
       ],
       "response_types": [
         "none"
       ],
       "allow_all_scopes": false,
       "allow_scopes": [
         "'$SCOPE_ID'"
       ],
       "labels": {
         "tier": "4x",
         "cap": "strict"
       }
     }')

Export the env variables:

export DAVE_CLIENT_SECRET=$(echo "$_response" | jq -r ".client_secret")
export DAVE_CLIENT_ID=$(echo "$_response" | jq -r ".id")

Create a client for Erin:

_response=$(curl -X POST "https://us.api.konghq.com/v1/auth-servers/$AUTH_SERVER_ID/clients" \
     --no-progress-meter --fail-with-body  \
     -H "Authorization: Bearer $KONNECT_TOKEN"\
     -H "Content-Type: application/json" \
     --json '{
       "name": "Erin",
       "grant_types": [
         "client_credentials"
       ],
       "response_types": [
         "none"
       ],
       "allow_all_scopes": false,
       "allow_scopes": [
         "'$SCOPE_ID'"
       ],
       "labels": {
         "tier": "4x",
         "orgUnit": "live-balance"
       }
     }')

Export the env variables:

export ERIN_CLIENT_SECRET=$(echo "$_response" | jq -r ".client_secret")
export ERIN_CLIENT_ID=$(echo "$_response" | jq -r ".id")

Create a client for Frank:

_response=$(curl -X POST "https://us.api.konghq.com/v1/auth-servers/$AUTH_SERVER_ID/clients" \
     --no-progress-meter --fail-with-body  \
     -H "Authorization: Bearer $KONNECT_TOKEN"\
     -H "Content-Type: application/json" \
     --json '{
       "name": "Frank",
       "grant_types": [
         "client_credentials"
       ],
       "response_types": [
         "none"
       ],
       "allow_all_scopes": false,
       "allow_scopes": [
         "'$SCOPE_ID'"
       ],
       "labels": {
         "tier": "4x",
         "orgUnit": "live-balance"
       }
     }')

Export the env variables:

export FRANK_CLIENT_SECRET=$(echo "$_response" | jq -r ".client_secret")
export FRANK_CLIENT_ID=$(echo "$_response" | jq -r ".id")

Create a client for Grace:

_response=$(curl -X POST "https://us.api.konghq.com/v1/auth-servers/$AUTH_SERVER_ID/clients" \
     --no-progress-meter --fail-with-body  \
     -H "Authorization: Bearer $KONNECT_TOKEN"\
     -H "Content-Type: application/json" \
     --json '{
       "name": "Grace",
       "grant_types": [
         "client_credentials"
       ],
       "response_types": [
         "none"
       ],
       "allow_all_scopes": false,
       "allow_scopes": [
         "'$SCOPE_ID'"
       ],
       "labels": {
         "tier": "4x",
         "groups": "suspended"
       }
     }')

Export the env variables:

export GRACE_CLIENT_SECRET=$(echo "$_response" | jq -r ".client_secret")
export GRACE_CLIENT_ID=$(echo "$_response" | jq -r ".id")

Each client’s ID and secret are now exported as $DAVE_CLIENT_ID/$DAVE_CLIENT_SECRET, $ERIN_CLIENT_ID/$ERIN_CLIENT_SECRET, $FRANK_CLIENT_ID/$FRANK_CLIENT_SECRET, and $GRACE_CLIENT_ID/$GRACE_CLIENT_SECRET, alongside Carol’s.

Verify claim resolution before requesting tokens

Confirm each client’s claims resolve as expected before using them for budget enforcement, using the /v1/auth-servers/$AUTH_SERVER_ID/clients/$CLIENT_ID/test-claim endpoint. For example, test Dave’s client:

curl -X POST "https://us.api.konghq.com/v1/auth-servers/$AUTH_SERVER_ID/clients/$DAVE_CLIENT_ID/test-claim" \
     --no-progress-meter --fail-with-body  \
     -H "Authorization: Bearer $KONNECT_TOKEN"\
     -H "Content-Type: application/json" \
     --json '{
       "budget_tier": "${ .Client.Labels.tier | default \"baseline\" }",
       "budget_cap": "${ .Client.Labels.cap | default \"\" }",
       "budget_org": "${ .Client.Labels.orgUnit | default \"unassigned\" }",
       "kong_groups": "${ .Client.Labels.groups | default \"\" | splitList \",\" | compact | toJson }"
     }'

The response resolves to budget_tier: "4x" and budget_cap: "strict". Dave has no orgUnit or groups label, so budget_org and kong_groups are absent from the response entirely rather than showing their defaults, confirming Dave gets his tier and his individual cap, but isn’t part of an org pool.

You now have $ISSUER_URL and a client_id/client_secret pair per persona. Use these in Enforce tiered AI budgets on an AI Model with Kong Identity to project the claims onto request headers for budget enforcement.

FAQs

A dynamic claim reads a label off whichever client requests the token, so one claim definition serves every client. Without labels, each new tier, cap, or org value would need its own claim and its own scope-gating logic.

No, the secret is only shared once when the client is created. Store it securely.

Yes. Create additional clients under the same auth server for new callers, or reference the same issuer from multiple openid-connect AI Identity Providers.

Yes, and nothing downstream would change. Kong reads budget_tier, budget_cap, budget_org, and kong_groups as header values and never sees where they came from. Okta could compute the same four claims from real group membership (isMemberOfGroupName) or from user profile attributes; the per-client labels used here are effectively that same attribute-driven model already, just with no group membership concept at all.

By design. Kong Identity computes tier, cap, org, and entitlement class at token-issue time; Kong only ever matches the resulting header values. Anything the auth server can compute before signing arrives as a tamper-proof value Kong can act on, with no Kong object, no sync, and no drift.

Because a missing default doesn’t fail loudly. No claim means no header, which means no rate-limiting match at all for that caller, not a soft fallback to some limit. A fail-safe policy matched on the subject header alone is worth carrying downstream precisely because of this: it bounds a caller whose claims come back missing instead of leaving them unlimited.

Help us make these docs great!

Kong Developer docs are open source. If you find these useful and want to make them better, contribute today!