Restrict and Rename AI Models by Identity
Model access policies restrict which AI models a caller can reach through the AI Gateway, and control what a model discovery call (GET /v1/models) returns to them.
Both decisions can read the caller's JWT claims, so a chat client that lists models automatically (for example, Open WebUI) can show a different catalog to each caller,
as long as it forwards each user's own token.
A model alias is the exception: it applies the same way to every caller, regardless of claims.
This guide configures a single Messages API route so that:
- Only callers in the
admingroup can reachclaude-opus-4-5. - Everyone's model list hides
claude-haiku-4-5, but a caller who already knows its name can still reach it directly. claude-opus-4-5is displayed in the model list under the custom nameacme-flagshipinstead of its real identifier, and a request for either name reaches the same model.policiesandlistPoliciesare two independent rule sets.policiesgoverns which requests reach the provider,listPoliciesgoverns what the model list shows. Hiding a model from the list in Step 2 doesn't deny it at request time on its own.- The alias applies to every caller. Step 3 shows how this affects the
policiesrule from Step 1.
The same pattern applies to Chat Completion, Responses API, and Bedrock Mantle.
Google Agent Platform supports the request-time half only, since it has no /v1/models endpoint to filter.
Prerequisites
-
AI Gateway enabled:
helm upgrade traefik traefik/traefik -n traefik --wait \--reset-then-reuse-values \--set hub.aigateway.enabled=true -
A JWT middleware in front of the route, issuing claims your identity provider populates from group membership (for example, a
groupsclaim from Keycloak or Okta). Model access policies read these claims from the request context. They don't authenticate the request themselves. Run it before the AI middleware in the middleware chain. Without claims in the request context, everyjwt.*condition is false. Anallowrule for a group then matches nobody, and adenyrule keyed on a claim matches nobody. -
Optional: a chat client that sends the signed-in user's JWT as the bearer token. The steps in this guide use
curl, so you only need a client to try the model list in a chat client such as Open WebUI. Open WebUI sends the connection's static API key by default, which the JWT middleware doesn't accept. To send the signed-in user's OAuth access token instead, setauth_typetosystem_oauthon the OpenAI connection, for exampleOPENAI_API_CONFIGS='{"0":{"auth_type":"system_oauth"}}'. Open WebUI also hides models that have no access settings from users with theuserrole, so a new user sees an empty model list until an admin grants access in Open WebUI. To make the gateway the only filter, setBYPASS_MODEL_ACCESS_CONTROL=true. -
An Anthropic API key, and a Kubernetes
ExternalNameservice pointing atapi.anthropic.com(see Messages API Requirements).
Step 1: Reserve a model for one group
Add a policies rule that only allows the admin group to reach claude-opus-4-5. Any other request for claude-opus-4-5 is denied, and every other model stays reachable by defaultAction: allow:
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: messagesapi
spec:
plugin:
messages-api:
allowModelOverride: true
policies:
- match: Prefix(`req.model`, `claude-opus-4-5`) && Contains(`jwt.groups`, `admin`)
action: allow
- match: Prefix(`req.model`, `claude-opus-4-5`)
action: deny
defaultAction: allow
apiKey: urn:k8s:secret:ai-keys:anthropic-api-key
The rules use Prefix so they also cover dated variants of the name, such as claude-opus-4-5-20251101.
See Match every name the provider accepts for other ways to match model names.
policies and defaultAction require allowModelOverride: true.
When it's false, the middleware always uses the configured model, so there's no caller-chosen model to evaluate. The middleware rejects the configuration, and every request on the route returns 404.
Leave model unset so allowModelOverride defaults to true.
A policies match expression can check the model the request targets with req.model, the caller's JWT claims such as jwt.groups, or both together, as the allow rule in this step does.
Combining both is what makes a rule specific to one model for one identity, instead of a rule that only checks identity and ends up covering every model.
With this configuration and allowModelOverride: true, the outcome depends on both who is asking and what they ask for:
- A caller with the
admingroup requestingclaude-opus-4-5matches the first rule and is allowed. - Any other caller requesting
claude-opus-4-5doesn't match the first rule, falls through to the second rule, and is denied. - A request for any other model doesn't match either rule and is allowed by
defaultAction: allow.
The first matching rule decides, so put the more specific allow rule before the broader deny.
Reserving claude-opus-4-5 for the admin group doesn't restrict access to any other model.
Step 2: Hide a model family from the list
Add a listPolicies rule that hides claude-haiku-4-5 from every caller's model list:
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: messagesapi
spec:
plugin:
messages-api:
allowModelOverride: true
policies:
- match: Prefix(`req.model`, `claude-opus-4-5`) && Contains(`jwt.groups`, `admin`)
action: allow
- match: Prefix(`req.model`, `claude-opus-4-5`)
action: deny
defaultAction: allow
listPolicies:
- match: Prefix(`res.model.id`, `claude-haiku-4-5`)
action: hide
apiKey: urn:k8s:secret:ai-keys:anthropic-api-key
listPolicies runs independent of allowModelOverride.
The Prefix match also covers dated variants of the name, such as claude-haiku-4-5-20251001, so this hides claude-haiku-4-5 from GET /v1/models for every caller, regardless of the policies rules from Step 1.
Hiding a model from the list doesn't deny it at request time. A caller who already knows the name claude-haiku-4-5 can still request it directly, and this configuration allows that request.
Neither policies rule from Step 1 addresses claude-haiku-4-5, so it falls to defaultAction: allow. Only claude-opus-4-5 is restricted.
Every other model, including any model hidden from the list, stays reachable by default.
If you also want to deny direct requests to claude-haiku-4-5, add the same pair of rules for it that Step 1 adds for claude-opus-4-5:
one allow rule scoped to the callers who should still reach it, and one deny rule for everyone else.
Step 3: Publish a custom name for a model
Add a modelAliases entry so the Claude Opus 4.5 model is displayed under the name acme-flagship instead of its own identifier:
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: messagesapi
spec:
plugin:
messages-api:
allowModelOverride: true
policies:
- match: Prefix(`req.model`, `claude-opus-4-5`) && Contains(`jwt.groups`, `admin`)
action: allow
- match: Prefix(`req.model`, `claude-opus-4-5`)
action: deny
defaultAction: allow
listPolicies:
- match: Prefix(`res.model.id`, `claude-haiku-4-5`)
action: hide
modelAliases:
- model: claude-opus-4-5-20251101
exposedAs: acme-flagship
apiKey: urn:k8s:secret:ai-keys:anthropic-api-key
Set model to the identifier exactly as your provider's model list returns it, because modelAliases compares exact strings. This example uses claude-opus-4-5-20251101.
modelAliases has no match field, so this mapping applies to every caller. You can't restrict it to the admin group the way policies restricts access to claude-opus-4-5 in Step 1.
Every caller's model list now shows acme-flagship in place of claude-opus-4-5-20251101, including callers outside the admin group.
When a caller sends a request naming acme-flagship, Traefik Hub replaces it with claude-opus-4-5-20251101 before evaluating policies and before forwarding the request to Anthropic.
Traefik Hub then rewrites Anthropic's response so it reports the model as acme-flagship again, matching the name the caller used, instead of the upstream identifier that was actually forwarded.
Traefik Hub resolves modelAliases first, then evaluates policies against the resolved upstream identifier.
The order of the fields in the YAML doesn't change this, so modelAliases resolves first wherever you place it.
Because of this order, the policies rules from Step 1 don't need to change.
They already match every name that starts with claude-opus-4-5, and a request for acme-flagship resolves to claude-opus-4-5-20251101 before policies sees it.
An admin caller can also request claude-opus-4-5 or claude-opus-4-5-20251101 directly, since the Prefix rules match both.
Step 4: Test the configuration
Attach the middleware to a route, then send a request as a caller with the admin group claim:
curl -s -X POST "https://ai.example.com/v1/messages" \
-H "Authorization: Bearer <admin-jwt>" \
-H "Content-Type: application/json" \
-d '{
"model": "acme-flagship",
"max_tokens": 100,
"messages": [{"role": "user", "content": "Hello"}]
}' | jq .
The request reaches claude-opus-4-5-20251101 and the response reports the model as acme-flagship. Repeat the same request with a JWT that has no admin claim:
curl -s -X POST "https://ai.example.com/v1/messages" \
-H "Authorization: Bearer <non-admin-jwt>" \
-H "Content-Type: application/json" \
-d '{
"model": "acme-flagship",
"max_tokens": 100,
"messages": [{"role": "user", "content": "Hello"}]
}' | jq .
This request is refused with a 403 (the default statusCodeOnDeny).
The body is a Messages API message with stop_reason: refusal that names the model the caller sent, acme-flagship, not the upstream identifier.
acme-flagship resolves to claude-opus-4-5-20251101 before policies evaluates it, the caller's claims don't satisfy the allow rule,
and the second policies rule denies every name that starts with claude-opus-4-5 for everyone else.
Then check the model list:
curl -s "https://ai.example.com/v1/models" -H "Authorization: Bearer <admin-jwt>" | jq .
The list shows acme-flagship in place of claude-opus-4-5-20251101 and omits claude-haiku-4-5, the same way for every caller.
Neither the modelAliases mapping nor the listPolicies hide rule in this configuration reads the caller's JWT claims.
Only the two /v1/messages requests in this step differed by caller. The admin caller's request succeeded, and the non-admin caller's was denied.
policies decides that difference by reading the admin group claim. The model list doesn't read any claims, so it looks the same for every caller.
Related Content
- Read the full field reference, evaluation order, and observability behavior in Model Access Policies.
- Read Understanding TBAC to see the same expression language applied to MCP tool access.
- Read AI Gateway Observability to find the
traefik.hub.gen_ai.upstream.modelattribute that reports the real identifier behind a renamed model. - Configure the JWT middleware to issue the claims model access policies read.
