Skip to content

Authorize users and models with JWT

Use one public inference endpoint for separate tenant and model permissions. The Keycloak realm and base rules come from the dedicated bootstrap.

Lab sequence and validation scope

Start with lab preparation and common API conventions. These commands belong to a multi-page lab, not standalone mutations. The Quickstart provides the canonical mutation lifecycle. Read the evidence limits before recording a result.

Before you start

Complete setup and obtain the per-user credentials described below.

Focus of this page: Change JWT profile/model permissions while keeping the client-to-backend route fixed.

Topology and execution locations

flowchart TD
    C["Client namespace l3h1<br/>192.0.2.1"] --> G["Gateway llb1<br/>VIP 192.0.2.254"]
    G --> L["Llama echo l3ep1<br/>198.51.100.1"]
    G --> M["Mistral echo l3ep2<br/>203.0.113.1"]
    K["Keycloak kc-aigw<br/>Docker bridge"] -.->|"JWT issuer / JWKS"| G
    G -.-> P["Policy store pg-jwtauth<br/>Docker bridge"]
    O["Operator on Linux host"] -.->|"REST via l3h1 / port 11111"| G
    O -.->|"independent fixture observations"| L
    style G fill:#e1f5fe,stroke:#0288d1,color:#1f2937
    style L fill:#e8f5e9,stroke:#43a047,color:#1f2937
    style M fill:#e8f5e9,stroke:#43a047,color:#1f2937

These are Linux containers/namespaces on one disposable host. The model names identify synthetic echoes, not loaded GPU models. The baseline listener is VIP 192.0.2.254:2040; TLS, capacity and failure exercises add the explicit listener ports shown in their steps. Keycloak/JWKS and PostgreSQL use inspected Docker-bridge addresses.

Where What happens here
Host operator Prepare private LAB files, issue credentials and run namespace commands.
l3h1 Send management calls and client traffic into llb1.
llb1 Enforce identity, quota and capacity before choosing a backend.
l3ep1 / l3ep2 Observe independent Llama/Mistral receipt and hold/release fixtures.
kc-aigw / pg-jwtauth Supply identity and policy state; they are not on the inference response path.

Walkthrough

Prerequisites and profile configuration

Prepare Alice and Bob credentials in setup. On the fixture realm Alice has tenant-a and model:llama-70b; Bob has tenant-b and model:mistral-7b. Carol deliberately has no tenant. A valid signature alone is insufficient for attributable admission.

Profile field Required / example / default Meaning and change impact
name, issuer Required; kc, $ISSUER Referenced profile and exact JWT issuer; create before the LB rule
jwks_url Optional explicit issuer certs URL Omit for OIDC discovery; unreachable keys differ from bad signatures
audiences Optional ["aigw-api"]; empty skips this check Validates aud or azp; set intentionally
leeway_sec, refresh_sec Optional 2 / 300 seconds; zero/omitted resolves to 30 / 3600 Clock tolerance and key refresh, not token lifetime
tenant_claim, user_claim Optional tenant_id, sub defaults Changing these changes quota attribution; user is the subject, not login name
roles_claim, model_role_prefix Optional realm_access.roles, model: defaults Model access role interpretation
forward_identity, authorization_passthrough Optional false Independent verified identity and bearer forwarding switches

The first block re-applies the base kc profile and affects its referencing rules; use only the disposable lab. If a base model rule already matches, reuse it after GET; a no-op POST may return 409 and does not prove a new apply.

jq -n --arg iss "$ISSUER" '{name:"kc",issuer:$iss,
  jwks_url:($iss+"/protocol/openid-connect/certs"),audiences:["aigw-api"],
  leeway_sec:2,refresh_sec:300,tenant_claim:"tenant_id",user_claim:"sub",
  roles_claim:"realm_access.roles",model_role_prefix:"model:",
  forward_identity:false,authorization_passthrough:false}' > "$LAB/kc.json"
sudo ip netns exec l3h1 curl -sS -i -X POST "$API/config/ai/jwtauthprofile" \
  -H 'Content-Type: application/json' --data-binary @"$LAB/kc.json"
sudo ip netns exec l3h1 curl -sS "$API/config/ai/jwtauthprofile" | \
  jq --arg n "$(jq -r .name "$LAB/kc.json")" '[.jwtAuthProfileAttr[] | select(.name==$n)]'
sudo ip netns exec l3h1 curl -sS "$API/config/ai/jwtauthprofile" | jq .
sudo ip netns exec l3h1 curl -sS "$API/config/loadbalancer/all" | \
  jq '.lbAttr[] | select(.serviceArguments.port==2040)'
{
  "serviceArguments": {
    "externalIP": "192.0.2.254",
    "port": 2040,
    "protocol": "tcp",
    "sel": 0,
    "mode": 4,
    "host": "192.0.2.254",
    "path_prefix": "/",
    "path_match_mode": "prefix",
    "model_name": "llama-70b",
    "api_key_auth": "jwt",
    "jwt_auth_profile": "kc",
    "inactiveTimeOut": 30
  },
  "endpoints": [
    {
      "endpointIP": "198.51.100.1",
      "targetPort": 8080,
      "weight": 1
    }
  ]
}
sudo ip netns exec l3h1 curl -sS -i -X POST "$API/config/loadbalancer" -H 'Content-Type: application/json' --data-binary @"$LAB/examples/jwt-llama.json"
sudo ip netns exec l3h1 curl -sS "$API/config/loadbalancer/all" | \
  jq --argjson p "$(jq -r .serviceArguments.port "$LAB/examples/jwt-llama.json")" '[.lbAttr[] | select(.serviceArguments.port==$p) | {service:.serviceArguments,endpoints:.endpoints}]'
sudo ip netns exec l3h1 curl -sS -i -X POST "$API/config/loadbalancer" -H 'Content-Type: application/json' --data-binary @"$LAB/examples/jwt-mistral.json"
sudo ip netns exec l3h1 curl -sS "$API/config/loadbalancer/all" | \
  jq --argjson p "$(jq -r .serviceArguments.port "$LAB/examples/jwt-mistral.json")" '[.lbAttr[] | select(.serviceArguments.port==$p) | {service:.serviceArguments,endpoints:.endpoints}]'

Verify positive and denied traffic

Expect Alice→Llama 200 with Llama receipt 1 / Mistral 0; Alice→Mistral 403 model_not_allowed with both 0; Bob→Mistral 200 with Mistral 1. Missing, malformed, wrong-audience and wrong-issuer tokens return 401 (typed errors missing_token or invalid_token), receipt 0. Bootstrap ports 2042/2043 isolate audience/issuer mismatch.

The short-token check must run after JWT exp plus the configured leeway. The five-second wait below is a fixture-specific minimum; if it has not expired, use the decoded exp-based wait shown in the TLS chapter. Refresh Alice afterwards. A timeout or HTTP 000 is not an authentication rejection result.

sudo ip netns exec l3h1 curl -sS -i --config "$LAB/alice.curl" \
  -H 'Content-Type: application/json' -H "X-Test-Nonce: $RUN-alice-llama" \
  --data-binary @"$LAB/examples/llama-request.json" "http://$VIP:2040/v1/chat/completions"
sudo ip netns exec l3h1 curl -sS -i --config "$LAB/alice.curl" \
  -H 'Content-Type: application/json' -H "X-Test-Nonce: $RUN-alice-denied" \
  --data-binary @"$LAB/examples/mistral-request.json" "http://$VIP:2040/v1/chat/completions"
sudo ip netns exec l3h1 curl -sS -i --config "$LAB/bob.curl" \
  -H 'Content-Type: application/json' -H "X-Test-Nonce: $RUN-bob-mistral" \
  --data-binary @"$LAB/examples/mistral-request.json" "http://$VIP:2040/v1/chat/completions"
sudo ip netns exec l3h1 curl -sS -i -H 'Content-Type: application/json' \
  -H "X-Test-Nonce: $RUN-no-token" --data-binary @"$LAB/examples/llama-request.json" \
  "http://$VIP:2040/v1/chat/completions"
printf 'header = "Authorization: Bearer not-a-jwt"\n' > "$LAB/invalid.curl"
sudo ip netns exec l3h1 curl -sS -i --config "$LAB/invalid.curl" \
  -H 'Content-Type: application/json' -H "X-Test-Nonce: $RUN-invalid-token" \
  --data-binary @"$LAB/examples/llama-request.json" "http://$VIP:2040/v1/chat/completions"
sudo ip netns exec l3h1 curl -sS -i --config "$LAB/alice.curl" \
  -H 'Content-Type: application/json' -H "X-Test-Nonce: $RUN-wrongaud" \
  --data-binary @"$LAB/examples/llama-request.json" "http://$VIP:2042/v1/chat/completions"
sudo ip netns exec l3h1 curl -sS -i --config "$LAB/alice.curl" \
  -H 'Content-Type: application/json' -H "X-Test-Nonce: $RUN-wrongiss" \
  --data-binary @"$LAB/examples/llama-request.json" "http://$VIP:2043/v1/chat/completions"
curl -sS --max-time 10 -X POST "$TOKEN_URL" --data-urlencode client_id=aigw-short \
  --data-urlencode grant_type=password --data-urlencode username=alice --data-urlencode password=alicepw \
  -o "$LAB/short.token.json"
jq -r '"header = \"Authorization: Bearer " + .access_token + "\""' "$LAB/short.token.json" > "$LAB/short.curl"
sleep 5
sudo ip netns exec l3h1 curl -sS -i --config "$LAB/short.curl" -H 'Content-Type: application/json' \
  -H "X-Test-Nonce: $RUN-expired" --data-binary @"$LAB/examples/llama-request.json" "http://$VIP:2040/v1/chat/completions"

Cleanup: isolated profile and full-key model rules

Use kc-manual and port 2244 for the deletion exercise, preserving bootstrap-owned kc/2040. Repeat the positive and denied requests above against 2244 with new nonces before deletion. A referenced profile DELETE must return 409 and leave it intact. Remove both model full keys, expect the port filter [], then delete the profile and expect its list filter []. Recheck 2040 positive traffic.

jq '.name="kc-manual"' "$LAB/kc.json" > "$LAB/kc-manual.json"
sudo ip netns exec l3h1 curl -sS -i -X POST "$API/config/ai/jwtauthprofile" \
  -H 'Content-Type: application/json' --data-binary @"$LAB/kc-manual.json"
sudo ip netns exec l3h1 curl -sS "$API/config/ai/jwtauthprofile" | \
  jq --arg n "$(jq -r .name "$LAB/kc-manual.json")" '[.jwtAuthProfileAttr[] | select(.name==$n)]'
jq '.serviceArguments.port=2244 | .serviceArguments.jwt_auth_profile="kc-manual"' "$LAB/examples/jwt-llama.json" > "$LAB/jwt-manual-llama.json"
jq '.serviceArguments.port=2244 | .serviceArguments.jwt_auth_profile="kc-manual"' "$LAB/examples/jwt-mistral.json" > "$LAB/jwt-manual-mistral.json"
sudo ip netns exec l3h1 curl -sS -i -X POST "$API/config/loadbalancer" -H 'Content-Type: application/json' --data-binary @"$LAB/jwt-manual-llama.json"
sudo ip netns exec l3h1 curl -sS "$API/config/loadbalancer/all" | \
  jq --argjson p "$(jq -r .serviceArguments.port "$LAB/jwt-manual-llama.json")" '[.lbAttr[] | select(.serviceArguments.port==$p) | {service:.serviceArguments,endpoints:.endpoints}]'
sudo ip netns exec l3h1 curl -sS -i -X POST "$API/config/loadbalancer" -H 'Content-Type: application/json' --data-binary @"$LAB/jwt-manual-mistral.json"
sudo ip netns exec l3h1 curl -sS "$API/config/loadbalancer/all" | \
  jq --argjson p "$(jq -r .serviceArguments.port "$LAB/jwt-manual-mistral.json")" '[.lbAttr[] | select(.serviceArguments.port==$p) | {service:.serviceArguments,endpoints:.endpoints}]'
sudo ip netns exec l3h1 curl -sS "$API/config/ai/jwtauthprofile" | jq '[.jwtAuthProfileAttr[] | select(.name=="kc-manual")]'
sudo ip netns exec l3h1 curl -sS "$API/config/loadbalancer/all" | jq '[.lbAttr[] | select(.serviceArguments.port==2244)]'
sudo ip netns exec l3h1 curl -sS -i -X DELETE "$API/config/ai/jwtauthprofile/kc-manual"
sudo ip netns exec l3h1 curl -sS -i -X DELETE \
  "$API/config/loadbalancer/hosturl/$VIP/externalipaddress/$VIP/port/2244/protocol/tcp?path_prefix=/&path_match_mode=prefix&model_name=llama-70b"
sudo ip netns exec l3h1 curl -sS -i -X DELETE \
  "$API/config/loadbalancer/hosturl/$VIP/externalipaddress/$VIP/port/2244/protocol/tcp?path_prefix=/&path_match_mode=prefix&model_name=mistral-7b"
sudo ip netns exec l3h1 curl -sS "$API/config/loadbalancer/all" | jq '[.lbAttr[] | select(.serviceArguments.port==2244)]'
sudo ip netns exec l3h1 curl -sS -i -X DELETE "$API/config/ai/jwtauthprofile/kc-manual"
sudo ip netns exec l3h1 curl -sS "$API/config/ai/jwtauthprofile" | jq '[.jwtAuthProfileAttr[] | select(.name=="kc-manual")]'

Troubleshooting and cleanup

Use diagnosis and scoped cleanup. Restore this chapter's changes before moving to another policy experiment.

Check your result

Pair accepted and forbidden model requests with tenant/user identity and backend receipts.

Compare API readback with the normal request, the deliberate denial, and an independent backend observation. Check the chapter's exact HTTP code and error body; HTTP 200 alone does not prove quota, identity, queue order, or resource return.

Next steps and cleanup

Restore the chapter's temporary policy before continuing in the same lab. Keep the original configuration and private evidence; use the final cleanup chapter when the entire lab is finished. Read the lab starting point if required values are missing. Use cleanup and recovery when ending the run.