AI Citadel Access Contracts Policy
This guide to expand on what policies are available out of the box for use with the AI Citadel Access Contracts Bicep package, and how to customize them for each use case being onboarded.
Available Policies Snippets
The following policy snippets can be applied as needed for the product policy access as part of the Citadel Access Contracts:
Asset-Type-Aware Policies (LLM + Tools + Agents in one product)
An access contract can grant a single product access to a mix of asset types — LLM inference APIs, published Tools (MCP), and published Agents (A2A). Because APIM does not expose the API type to policy at runtime, the product policy classifies each request with the set-asset-kind fragment and branches with a <choose>.
Product prefix follows the asset mix: LLM / TOOL / AGENT for a single type, or MULTI for two or more (e.g. MULTI-HR-ChatAgent-DEV). TOOL / AGENT / MULTI products default to policies/default-multi-product-policy.xml.
Classification — list the granted API resource names per type before including the fragment; anything unlisted defaults to llm (so LLM-only contracts are unaffected):
<inbound>
<base />
<!-- COMMON POLICIES (any asset type) go here, e.g. opt-in content safety -->
<!-- Classify the request as llm | tool | agent -->
<set-variable name="contractToolApis" value="weather-tool,ms-learn-tool" />
<set-variable name="contractAgentApis" value="hr-chat-agent" />
<include-fragment fragment-id="set-asset-kind" />
<choose>
<!-- LLM: model RBAC + token capacity (existing behavior) -->
<when condition="@(context.Variables.GetValueOrDefault<string>("assetKind","llm") == "llm")">
<include-fragment fragment-id="set-llm-requested-model" />
<set-variable name="allowedModels" value="gpt-4o,gpt-4.1" />
<include-fragment fragment-id="validate-model-access" />
<llm-token-limit counter-key="@(context.Subscription.Id)" tokens-per-minute="10000" estimate-prompt-tokens="false" token-quota="1000000" token-quota-period="Monthly" />
</when>
<!-- TOOL (MCP): request-based rate limit + call quota -->
<when condition="@(context.Variables.GetValueOrDefault<string>("assetKind","") == "tool")">
<rate-limit-by-key calls="60" renewal-period="60" counter-key="@(context.Subscription.Id + ":tool")" />
<quota-by-key calls="100000" renewal-period="2592000" counter-key="@(context.Subscription.Id + ":tool")" />
</when>
<!-- AGENT (A2A): request-based rate limit + call quota -->
<when condition="@(context.Variables.GetValueOrDefault<string>("assetKind","") == "agent")">
<rate-limit-by-key calls="30" renewal-period="60" counter-key="@(context.Subscription.Id + ":agent")" />
<quota-by-key calls="50000" renewal-period="2592000" counter-key="@(context.Subscription.Id + ":agent")" />
</when>
</choose>
</inbound>
Why request-based limits for Tools/Agents? llm-token-limit counts LLM tokens, which don't exist for a tool call or an agent turn. Tools and Agents are throttled by request count instead: rate-limit-by-key (calls per period) + quota-by-key (long-term call quota). The calls / renewal-period values are baked per contract (like llm-token-limit's TPM/quota).
Common policies — anything that should run for every asset type (e.g. opt-in content safety, custom alerting) goes before the set-asset-kind classification so it applies regardless of assetKind.
Usage metrics are emitted by the API-level baseline policies (llm-usage for LLM, mcp-usage / a2a-usage for published Tools/Agents), so the product policy focuses on access control and throttling only.
✅ Backward compatible — existing
LLM-contracts keep the LLM product policy and never includeset-asset-kind.
Model Access Control Policy
The model access control policy restricts which LLM models a product can access. This is implemented using the validate-model-access policy fragment.
Basic Usage:
<inbound>
<!-- Extract and validate model parameter from request -->
<include-fragment fragment-id="set-llm-requested-model" />
<!-- Setting allowed models variable (comma-separated list) -->
<set-variable name="allowedModels" value="gpt-4o,deepseek-r1" />
<!-- Validate model access based on allowedModels -->
<include-fragment fragment-id="validate-model-access" />
</inbound>
How It Works:
-
The
set-llm-requested-modelfragment extracts the model from the request:- From request body
{"model": "gpt-4o", ...}for Universal LLM API - From URL path
/deployments/{deployment-id}/...for Azure OpenAI API - Returns
"non-llm-request"for GET operations (like listing available models)
- From request body
-
The
validate-model-accessfragment validates the requested model:- Non-LLM requests (GET operations): Usually reference meta data endpoints that discover allowed models
- Empty
allowedModels: All models are allowed - Model not in list: Returns 401 Unauthorized with structured JSON error
Error Response Format:
When access is denied, the policy returns a structured JSON error:
{
"error": {
"message": "Access to model 'gpt-4' is not allowed for this product.",
"type": "access_error",
"code": "unauthorized_model_access",
"allowed_models": "gpt-4o,deepseek-r1"
}
}
Configuration Options:
| Variable | Description | Example |
|---|---|---|
allowedModels |
Comma-separated list of allowed model names (no white-space) | "gpt-4o,deepseek-r1,Phi-4" |
NOTE: Non-LLM requests (such as GET operations for listing available models) are automatically allowed and do not require model validation. This ensures auxiliary endpoints function without needing a model parameter.
Model Capacity Management Policy
The below policy snippet, enforces a token limit per subscription but for all models being access via this product.
<inbound>
<!-- Capacity management - Subscription Level: allow only assigned tpm for each HR use case subscription -->
<llm-token-limit counter-key="@(context.Subscription.Id)"
tokens-per-minute="300"
estimate-prompt-tokens="false"
tokens-consumed-header-name="consumed-tokens"
remaining-tokens-header-name="remaining-tokens"
token-quota="100000"
token-quota-period="Monthly"
retry-after-header-name="retry-after" />
</inbound>
To further control capacity management per model per subscription, you can extend the above policy snippet to include model specific token limits by leveraging the requestedModel variable set via the set-llm-requested-model fragment.
<!-- Inboud Section of the Product Policy -->
<!-- Extract and validate model parameter from request and save it to requestedModel -->
<include-fragment fragment-id="set-llm-requested-model" />
<!-- Capacity management - Subscription + Model Level: allow only assigned tpm for each model per subscription -->
<choose>
<when condition="@((string)context.Variables["requestedModel"] == "gpt-4o")">
<llm-token-limit
counter-key="@(context.Subscription.Id + "-" + context.Variables["requestedModel"])"
tokens-per-minute="10000"
estimate-prompt-tokens="false"
tokens-consumed-header-name="consumed-tokens"
remaining-tokens-header-name="remaining-tokens"
token-quota="100000"
token-quota-period="Monthly"
retry-after-header-name="retry-after" />
</when>
<when condition="@((string)context.Variables["requestedModel"] == "DeepSeek-R1")">
<llm-token-limit
counter-key="@(context.Subscription.Id + "-" + context.Variables["requestedModel"])"
tokens-per-minute="2000"
estimate-prompt-tokens="false"
tokens-consumed-header-name="consumed-tokens"
remaining-tokens-header-name="remaining-tokens"
token-quota="10000"
token-quota-period="Weekly"
retry-after-header-name="retry-after" />
</when>
<otherwise>
<!-- Default model token limit for other models -->
<llm-token-limit
counter-key="@(context.Subscription.Id + "-default")"
tokens-per-minute="1000"
estimate-prompt-tokens="false"
tokens-consumed-header-name="consumed-tokens"
remaining-tokens-header-name="remaining-tokens"
token-quota="5000"
token-quota-period="Monthly"
retry-after-header-name="retry-after" />
</otherwise>
</choose>
LLM Usage Customization Policy
By default, AI Citadel Gateway is configured to collect the following data points for LLM usage tracking:
- Standard Dimensions (currently can't be modified):
- Region
- Service ID
- Service Name
- Service Type
- Citadel Added Dimensions:
- Product Name
- DeploymentName (based on requestedModel variable)
- Backend ID
- appId (looks for variable named appId, fall back to subscription ID and then to "Portal-Admin" if not found)
- Custom dimensions:
- customDimension1 (by default looks for a variable named customDimension1)
- customDimension2 (by default looks for a variable named customDimension2)
Standard setup is already included in the default policies, but you can customize it further by setting up the following variables in the product policy inbound section:
<!-- Map appId from x-app-id header with safe defaults -->
<set-variable name="appId" value="@{
var requestedAppId = context.Request.Headers.GetValueOrDefault("x-app-id", null);
if (!string.IsNullOrEmpty(requestedAppId))
{
return requestedAppId;
}
return context.Subscription?.Id ?? "Portal-Admin";
}" />
<!-- Map customDimension1 from x-enduser-id header -->
<set-variable name="customDimension1" value="@(
context.Request.Headers.GetValueOrDefault("x-sub-agent-id", "general-agent")
)" />
<!-- Map customDimension2 from x-usecase-id header -->
<set-variable name="customDimension2" value="@(
context.Request.Headers.GetValueOrDefault("x-enduser-id", "anonymous-enduser")
)" />
NOTE: The above policy fragment assumes that the client application is passing
x-app-id,x-sub-agent-idandx-enduser-idheaders in the request. You can modify the header names as per your requirements or use different approach to set these variables.
LLM Usage Custom Dimensions Policy
The two custom dimensions (customDimension1 and customDimension2) are generic, free-form tracking fields that flow all the way through to Cosmos DB and become slicers / grouping columns in the Power BI Dashboard. Use them to attribute usage against any organization-specific context — for example end-user ID, session ID, sub-agent ID, cost center, department, or channel.
By default both dimensions resolve to NA. They only carry meaningful values once you set the matching context variables. You can configure them per access contract (recommended for use-case-specific context) or globally in the usage policy fragment (for context that is identical across all use cases).
Per Access Contract (use-case specific)
Set the customDimension1 / customDimension2 variables in the inbound section of the product policy. This is the recommended approach because each use case decides what business context matters and how to source it (headers, JWT claims, subscription attributes, etc.).
Source from client headers:
<inbound>
<base />
<!-- Track the calling end user (customDimension1) -->
<set-variable name="customDimension1" value="@(
context.Request.Headers.GetValueOrDefault("x-enduser-id", "anonymous-enduser")
)" />
<!-- Track the conversation/session (customDimension2) -->
<set-variable name="customDimension2" value="@(
context.Request.Headers.GetValueOrDefault("x-session-id", "NA-session")
)" />
</inbound>
Source from a validated JWT claim (for example, attribute usage to the calling identity or a tenant claim after security-handler runs):
<inbound>
<base />
<set-variable name="jwtRequired" value="true" />
<!-- Attribute usage to the department claim carried in the token -->
<set-variable name="customDimension1" value="@{
var jwt = context.Request.Headers.GetValueOrDefault("Authorization", "")?.Split(' ').LastOrDefault()?.AsJwt();
return jwt?.Claims.GetValueOrDefault("department", "unknown-dept") ?? "unknown-dept";
}" />
</inbound>
Static value per use case (useful to tag a fixed cost center to a contract):
<inbound>
<base />
<set-variable name="customDimension2" value="cost-center-4711" />
</inbound>
| Variable | Emitted as dimension | Typical source |
|---|---|---|
customDimension1 |
customDimension1 |
End-user ID, sub-agent ID, department, channel |
customDimension2 |
customDimension2 |
Session ID, cost center, tenant ID, request category |
Global default (all use cases)
If a dimension has the same meaning everywhere, embed the default directly in the frag-set-llm-usage.xml policy fragment instead of repeating it in every access contract. Set the variable before the <llm-emit-token-metric> block (or change the fallback in the <dimension> element). A per-product set-variable still overrides the global default for that specific access contract.
NOTE:
llm-emit-token-metricsupports a limited number of custom dimensions, which is why exactly two generic dimensions are exposed. Be mindful of cardinality — assigning very high-cardinality values (such as raw user IDs on high-traffic products) increases metric storage and query cost. For high-cardinality tracking, preferappIdor aggregate the value (e.g., hash or bucket) before emitting it.
Configuring Alerts Policy
The gateway ships a comprehensive, opt-in alerting fragment, raise-alert-events, that emits a single Application Insights custom metric (AI Gateway Alert, namespace ai-gateway-alerts) for a curated set of service-impacting events. It is already wired into all three inference APIs (Azure OpenAI, Universal LLM, Unified AI), so you do not need to include the fragment yourself — an access contract only sets toggle variables to tailor which categories it alerts on.
Event categories and their toggles:
alertType |
Trigger | Toggle variable | Default |
|---|---|---|---|
throttling |
HTTP 429 (per-minute token rate / TPM exceeded) |
alertOnThrottling |
On |
quota-exceeded |
HTTP 403 (long-term token-quota exhausted, AITokenQuotaExceeded) |
alertOnQuotaExceeded |
On |
backend-failure |
HTTP 5xx backend fault / connectivity error |
alertOnBackendFailure |
On |
auth-failure |
HTTP 401/403 — missing/invalid key, missing/invalid JWT, insufficient role, model-access denied |
alertOnAuthFailure |
Off |
content-safety |
llm-content-safety block |
alertOnContentSafety |
Off |
pii-failure |
PII anonymization failure (fail-closed 502) |
alertOnPiiFailure |
Off |
A master switch, alertsEnabled (default true), suppresses all alerting for the contract when set to false.
Where to set the toggles: set them in the inbound section (before or right after
<base/>). Context variables persist across sections, so an inbound toggle is honored whenraise-alert-eventsruns later inoutbound/on-error, and also at the authorization rejection points that emit in explicit mode (security-handler,validate-model-access).
Opt a mission-critical use case into additional categories:
<inbound>
<base />
<!-- Throttling + backend-failure are already on platform-wide.
Enable auth, content-safety, and PII alerting for this contract: -->
<set-variable name="alertOnAuthFailure" value="true" />
<set-variable name="alertOnContentSafety" value="true" />
<set-variable name="alertOnPiiFailure" value="true" />
</inbound>
Silence alerting for a noisy, non-critical use case:
<inbound>
<base />
<!-- Disable just the throttling category for a bursty sandbox... -->
<set-variable name="alertOnThrottling" value="false" />
<!-- ...or suppress ALL alerting for this contract: -->
<!-- <set-variable name="alertsEnabled" value="false" /> -->
</inbound>
Each event is emitted with the dimensions alertType, statusCode, productName, deploymentName, and backendId (the selected backend / pool), so a single Azure Monitor alert rule can be split/filtered per category, use case, model, or backend. emit-metric supports at most 5 custom dimensions.
NOTE: Detailed guidance on the alerting model and creating Azure Monitor alert rules is in the Throttling & Critical Event Alerting Guide. The broader resiliency context (how alerting complements the circuit breaker and failover) is in the Resiliency Guide — Alerting on Critical Events.
NOTE: The legacy
raise-throttling-eventsfragment (metricAI Throttling, namespacethrottling-events) is still deployed for backward compatibility but is superseded byraise-alert-events. Prefer the toggles above for new contracts.
Response Headers Policy
The set-response-headers policy fragment injects UAIG-* response headers that expose internal gateway state for debugging and observability. These headers help trace request processing through the gateway, including authentication context, model routing, backend selection, and cache operations.
By default, response headers are disabled. To enable them for a specific product, set the enableResponseHeaders variable to true in the product policy inbound section.
Basic Usage:
<inbound>
<base />
<!-- Enable advanced response headers for debugging -->
<set-variable name="enableResponseHeaders" value="@(true)" />
</inbound>
Headers Returned (when enabled):
| Header | Source Variable | Description |
|---|---|---|
UAIG-Auth-Type |
auth-type |
Authentication method (api-key, jwt, api-key-jwt, none) |
UAIG-User-Id |
user-id |
Authenticated user identifier |
UAIG-Subscription |
subscription-name |
APIM subscription name |
UAIG-Model-Id |
requestedModel |
Requested LLM model name |
UAIG-API-Type |
api-type |
Detected API type (e.g., azure-openai, universal-llm) |
UAIG-Processed-Path |
routing-processed-path |
Processed request path used for routing |
UAIG-API-Version |
selected-api-version |
Selected API version |
UAIG-Is-Streaming |
is-streaming |
Whether the request is a streaming request |
UAIG-Backend |
selected-backend |
Selected backend pool |
UAIG-Final-Path |
finalPath |
Final backend path after rewriting |
UAIG-Cache-Operation |
cache-operation |
Cache operation performed (hit/miss/skip) |
UAIG-Request-Id |
— | APIM request correlation ID |
UAIG-Gateway-Region |
— | Azure region of the APIM gateway |
How It Works:
- The
set-response-headersfragment is included in the outbound and on-error sections of all three API policies (Azure OpenAI, Universal LLM, Unified AI) - The fragment checks the
enableResponseHeadersvariable — if not set orfalse, no headers are injected - When enabled via a product policy, the fragment adds all
UAIG-*headers to the response using values set by upstream fragments during request processing
Configuration Options:
| Variable | Type | Default | Description |
|---|---|---|---|
enableResponseHeaders |
bool | false |
Set to @(true) to enable response header injection |
NOTE: Response headers expose internal gateway state and should only be enabled for development/debugging products. Avoid enabling them in production access contracts to prevent leaking internal routing details to clients.
Content Safety Policy
The llm-content-safety policy enforces content safety checks on large language model (LLM) requests (prompts) or responses (completions) by sending them to the Foundry AI Content Safety service. The policy can also enforce content safety checks on requests or responses for MCP tools or A2A Agent APIs managed in API Management.
You can configure the content safety policy to block or flag content based on your organization's requirements per use-case/access contract.
NOTE: Content Safety has a context input limit of 10K characters for both checking the content it self against harmful content and the prompt shield for detecting attacks. Currently APIM support natively chunking input requests over 10K chunks for content safety checks. Prompt Shield however are still limited by the 10K limit. If you have content larger than 10K in the request, it is better to disable
shield-promptwhile configuring the policy.
<inbound>
<!-- Content Safety Policy -->
<!-- Failure to pass content safety will result in 403 error -->
<llm-content-safety backend-id="content-safety-backend" shield-prompt="true" window-size="1000" window-overlap-size="200" enforce-on-completions="false">
<!-- 0 is most restrictive and can be set up-to 7 -->
<categories output-type="EightSeverityLevels">
<category name="Hate" threshold="3" />
<category name="Violence" threshold="3" />
</categories>
</llm-content-safety>
<!-- End of Content Safety Policy -->
</inbound>
JWT Authentication Policy
JWT (JSON Web Token) authentication adds a second security layer on top of subscription API keys. When enabled for a product, clients must provide both an api-key header and an Authorization: Bearer {token} header.
JWT validation is handled by the unified security-handler policy fragment, which is included in all three API endpoints (Azure OpenAI API, Universal LLM API, and Unified AI API). The fragment validates the token's audience, issuer, signature, and expiry against either gateway-level APIM named values or per-product custom overrides.
Full JWT setup guide: See JWT Authentication Guide for gateway-level configuration. Client identity & permissions: See JWT Client Identity and Permissions Guide for configuring client applications to acquire tokens.
Prerequisites:
- APIM named values configured:
JWT-TenantId,JWT-AppRegistrationId,JWT-Issuer,JWT-OpenIdConfigUrl - For Microsoft Entra ID: Run the
bicep/infra/entra-id-setupmodule to auto-provision app registration and named values - For other identity providers: Manually configure the APIM named values or use per-product custom overrides
APIM Named Values for JWT Configuration:
| Named Value | Description | Example (Entra ID) | Example (Auth0) |
|---|---|---|---|
JWT-OpenIdConfigUrl |
OpenID Connect discovery endpoint | https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration |
https://{domain}/.well-known/openid-configuration |
JWT-Issuer |
Expected token issuer | https://login.microsoftonline.com/{tenant}/v2.0 |
https://{domain}/ |
JWT-AppRegistrationId |
Expected audience claim | api://{client-id} |
https://your-api-identifier |
Basic Usage - Enable JWT with Gateway Defaults:
<inbound>
<base />
<!-- Enable JWT requirement for this product -->
<set-variable name="jwtRequired" value="true" />
<!-- Other policies (model access, capacity, etc.) -->
</inbound>
Advanced Usage - Enable JWT with Custom Identity Provider:
Access contracts can override the gateway's default JWT settings by setting custom variables. The security-handler checks for these overrides first, then falls back to the APIM named values if not set.
| Variable | Description | Falls back to Named Value |
|---|---|---|
jwtAudience |
Custom audience claim to validate | JWT-AppRegistrationId |
jwtIssuer |
Custom token issuer to validate | JWT-Issuer |
jwtOpenIdConfigUrl |
Custom OpenID Connect discovery URL | JWT-OpenIdConfigUrl |
<inbound>
<base />
<!-- Enable JWT requirement -->
<set-variable name="jwtRequired" value="true" />
<!-- Override JWT settings for a different identity provider (e.g., Auth0, Okta, separate Entra tenant) -->
<set-variable name="jwtAudience" value="https://my-custom-api-audience" />
<set-variable name="jwtIssuer" value="https://my-idp.example.com/" />
<set-variable name="jwtOpenIdConfigUrl" value="https://my-idp.example.com/.well-known/openid-configuration" />
<!-- Other policies (model access, capacity, etc.) -->
</inbound>
You can override any combination of settings — unset variables fall back to the gateway defaults:
<inbound>
<base />
<set-variable name="jwtRequired" value="true" />
<!-- Only override audience (issuer and OpenID config use gateway defaults) -->
<set-variable name="jwtAudience" value="api://custom-audience-for-this-product" />
</inbound>
How It Works:
-
The
security-handlerfragment (included in the API-level policy via<base />) detects the authentication method:api-key— only subscription key providedjwt— only Bearer token providedapi-key-jwt— both providednone— neither provided
-
API key is always validated first (APIM subscription validation)
-
If
jwtRequiredis"true"(set by product policy), JWT validation is enforced:- Token is validated against the OpenID Connect configuration endpoint
- Audience, issuer, and signature are verified
- User identity is extracted from the
azpclaim (client credentials flow) - Custom overrides (
jwtAudience,jwtIssuer,jwtOpenIdConfigUrl) are used if set, otherwise APIM named values apply
-
If a Bearer token is provided but
jwtRequiredis not set, the token is still validated (opportunistic validation) -
This behavior is uniform across all three API endpoints — the same
security-handlerfragment executes regardless of whether the request arrives via Azure OpenAI, Universal LLM, or Unified AI API
Output Variables Set by Security Handler:
| Variable | Description | Example Values |
|---|---|---|
auth-type |
Authentication method detected | "api-key", "jwt", "api-key-jwt", "none" |
subscription-name |
APIM subscription name | "LLM-HR-ChatAgent-DEV-SUB-01" |
user-id |
User identifier (from JWT or subscription) | "app-client-id" or "subscription-name" |
Error Responses:
| Scenario | HTTP Status | Error Code | Message |
|---|---|---|---|
| No API key | 401 | unauthorized |
Access denied. A valid API key is required. |
| JWT required but missing | 401 | jwt_required |
JWT Bearer token is required for this product. |
| Invalid JWT token | 401 | — | Access denied due to invalid or expired JWT bearer token. |
| JWT config missing | 503 | jwt_not_configured |
JWT authentication is not configured properly on the gateway. |
Token Acquisition (Client Credentials Flow - Entra ID):
POST https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id={entra-app-client-id}
&client_secret={entra-app-client-secret}
&scope={audience}/.default
Combining JWT with Other Policies:
JWT authentication works alongside all other access contract policies like model access control and capacity management.
NOTE: The
jwtRequiredvariable must be set within the product policy inbound section. Thesecurity-handlerfragment reads this variable during API-level policy execution.
Foundry ProjectManagedIdentity JWT Validation Policy
When an access contract targets Microsoft Foundry with the default connection auth mode
ProjectManagedIdentity (see Foundry connection authentication),
the Foundry project's managed identity presents an Entra ID Bearer token issued for the
cognitive services audience (https://cognitiveservices.azure.com), in addition to the APIM
subscription key that Foundry sends as the api-key custom header. The product policy therefore has
to validate both credentials: the subscription key (validated automatically by APIM) and the
JWT for that audience.
This reuses the exact same security-handler JWT validation described
above — the only difference is that the audience/issuer are overridden to match the managed
identity token instead of a client app registration. Managed identity tokens are v1 tokens, so the
issuer is https://sts.windows.net/{tenantId}/.
Product policy snippet (add to the inbound section):
<inbound>
<base />
<!-- Foundry ProjectManagedIdentity: require + validate the project-MI Bearer token for the
cognitive services audience, alongside the always-required api-key subscription key. -->
<set-variable name="jwtRequired" value="true" />
<set-variable name="jwtAudience" value="https://cognitiveservices.azure.com" />
<set-variable name="jwtIssuer" value="https://sts.windows.net/{{JWT-TenantId}}/" />
<set-variable name="jwtOpenIdConfigUrl" value="https://login.microsoftonline.com/{{JWT-TenantId}}/v2.0/.well-known/openid-configuration" />
<!-- Other policies (model access, capacity, etc.) -->
</inbound>
How it works:
- Foundry sends
api-key: <subscription key>(custom header) andAuthorization: Bearer <MI token>. - APIM validates the subscription key (api-key is always required).
jwtRequired=truemakes thesecurity-handlerfragment validate the Bearer token; thejwtAudience/jwtIssuer/jwtOpenIdConfigUrloverrides scope validation to the managed identity token (audiencehttps://cognitiveservices.azure.com).- Requests missing a valid token are rejected with
401 Unauthorized.
Configuration notes:
| Variable | Value | Notes |
|---|---|---|
jwtAudience |
https://cognitiveservices.azure.com |
Must equal managedIdentityAudience on the Foundry connection |
jwtIssuer |
https://sts.windows.net/{{JWT-TenantId}}/ |
v1 issuer for managed identity tokens |
jwtOpenIdConfigUrl |
https://login.microsoftonline.com/{{JWT-TenantId}}/v2.0/.well-known/openid-configuration |
v2.0 JWKS keys validate v1 token signatures |
{{JWT-TenantId}}is an APIM named value that always exists; it holds the real tenant ID only when the gateway was deployed withentraAuth=true. For MI Foundry contracts, deploy withentraAuth=trueor bake the literal tenant ID into the policy (the validation notebook bakes it in automatically so the generated policy is self-contained).- Optional hardening: the
security-handlervalidates audience + issuer only. To restrict to the specific project managed identity, add a check on the token'sazp/appidclaim equal to the MI's client (application) ID. - Set
authType = 'ApiKey'on the Foundry connection to skip this entirely (subscription key only) — fully backward compatible.
Validation: The Citadel Access Contracts test notebook generates this policy automatically for Foundry-targeted contracts and exercises it by attaching a real Entra ID Bearer token (for the same audience) to its direct HTTP tests.
App Role Authorization Policy
App role authorization adds fine-grained access control on top of JWT authentication. When enabled for a product, the security-handler fragment checks that the JWT token contains at least one of the required app roles in the roles claim. This is enforced after JWT validation, so the token must first pass audience, issuer, and signature checks.
The gateway's Entra ID app registration defines the following app roles (provisioned by entra-id-setup/setup.ps1):
| App Role | Value | Description |
|---|---|---|
| Model.Invoke | Model.Invoke |
Invoke LLM model endpoints (chat completions, embeddings) |
| MCP.Invoke | MCP.Invoke |
Invoke MCP tool endpoints |
| Agent.Invoke | Agent.Invoke |
Invoke agent endpoints |
Assign these roles directly to application service principals and managed
identities. Security-group assignments supply app roles to users, not to service
principals that are group members. The gateway evaluates the token's roles
claim; it does not resolve group membership or infer roles from a groups claim.
Full setup guides:
- JWT Authentication Guide — Gateway-level configuration
- JWT Client Identity and Permissions Guide — Assigning roles to client identities
Basic Usage — Require a Single Role:
<inbound>
<base />
<!-- Enable JWT requirement -->
<set-variable name="jwtRequired" value="true" />
<!-- Require the Model.Invoke app role -->
<set-variable name="requiredRoles" value="Model.Invoke" />
<!-- Other policies (model access, capacity, etc.) -->
</inbound>
Multiple Roles (OR logic) — Any Matching Role Grants Access:
<inbound>
<base />
<set-variable name="jwtRequired" value="true" />
<!-- Client must have at least one of these roles -->
<set-variable name="requiredRoles" value="Model.Invoke,Agent.Invoke" />
</inbound>
How It Works:
- The
security-handlerfragment validates the JWT token (audience, issuer, signature, expiry) - After successful JWT validation, the fragment extracts the
rolesclaim from the token - If
requiredRolesis set by the product policy, the fragment checks if ANY of the required roles exist in the token'srolesclaim (case-insensitive OR match) - If no matching role is found, the request is rejected with HTTP 403 Forbidden
Error Response Format:
When a required role is missing, the policy returns:
{
"error": {
"message": "Access denied. Required app role not found in token.",
"code": "insufficient_role",
"required_roles": "Model.Invoke",
"token_roles": "Agent.Invoke"
}
}
Configuration Options:
| Variable | Description | Example |
|---|---|---|
requiredRoles |
Comma-separated list of accepted app roles (OR logic) | "Model.Invoke" or "Model.Invoke,Agent.Invoke" |
NOTE: The
requiredRolesvariable is opt-in. If not set or empty, no role check is performed — this ensures backward compatibility with existing access contracts that only usejwtRequired.
Output Variables Set by Security Handler:
| Variable | Description | Example Values |
|---|---|---|
jwt-roles |
App roles extracted from the JWT token | "Model.Invoke,Agent.Invoke" or "" |
Recommended Policy Ordering:
<inbound>
<base />
<!-- 1. JWT Authentication -->
<set-variable name="jwtRequired" value="true" />
<!-- 2. App Role Authorization -->
<set-variable name="requiredRoles" value="Model.Invoke" />
<!-- 3. Model extraction and access control -->
<include-fragment fragment-id="set-llm-requested-model" />
<set-variable name="allowedModels" value="gpt-4o,gpt-4o-mini" />
<include-fragment fragment-id="validate-model-access" />
<!-- 4. Capacity management -->
<llm-token-limit counter-key="@(context.Subscription.Id)"
tokens-per-minute="5000"
estimate-prompt-tokens="false"
token-quota="100000"
token-quota-period="Monthly" />
<!-- 5. Content safety, PII, etc. -->
</inbound>
PII Handling Policy
AI Citadel Gateway supports PII processing using built-in policy fragments that leverage Azure AI Language Service for detection and anonymization. This allows you to protect sensitive data when sending requests to LLM backends \u2014 either by anonymizing PII before it reaches the backend, or by rejecting (blocking) any request that contains PII outright.
NOTE: Azure AI Language Service enforces a 5,120-character-per-document limit for PII detection (applies only to the inbound request content, not the generated response). The
pii-anonymizationfragment now handles larger payloads automatically by splitting the input into overlapping chunks, batching up to 5 chunks per Language Service request, and analyzing across multiple requests (up to ~125,000 characters total). Detection runs on the chunks only — the request body is never reassembled from redacted pieces — so the original request structure is fully preserved. Tune this behavior with the optionalpiiMaxChunkSizeandpiiChunkOverlapvariables (see the configuration table below). For full details see the PII Masking Guide.
Available PII Policy Fragments
| Fragment | Purpose | Description |
|---|---|---|
pii-anonymization |
Inbound | Detects and replaces PII with placeholders before sending to backend |
pii-deanonymization |
Outbound | Restores original PII values in the response |
pii-state-saving |
Outbound | Logs PII processing activity to Event Hub for auditing |
Configuration Variables
The following variables can be set in your product policy to configure PII processing:
| Variable | Required | Default | Description |
|---|---|---|---|
piiAnonymizationEnabled |
Yes | - | Set to "true" to enable PII detection/anonymization |
piiBlockingEnabled |
No | "false" |
Set to "true" to reject (block) requests that contain PII instead of anonymizing them |
piiConfidenceThreshold |
No | "0.8" |
Minimum confidence score (0.0-1.0) for PII detection |
piiEntityCategoryExclusions |
No | "" |
Comma-separated list of PII categories to exclude (e.g., "PersonType") |
piiDetectionLanguage |
No | "en" |
Language code for detection. Use "auto" for multilingual content |
piiRegexPatterns |
No | "" |
JSON array of custom regex patterns for additional PII detection |
piiMaxChunkSize |
No | "5000" |
Max characters per chunk sent to the Language Service (clamped to the 500–5120 service range). Only applies when the input exceeds one chunk |
piiChunkOverlap |
No | "250" |
Overlap characters between consecutive chunks to catch PII straddling a chunk boundary (clamped to ≤ piiMaxChunkSize / 2) |
piiInputContent |
Yes | - | The content to be anonymized (typically the request body) |
piiStateSavingEnabled |
No | "false" |
Set to "true" to enable Event Hub logging |
NOTE: For a complete list of PII entity categories, see Azure AI Language PII Entity Categories.
PII Anonymization/Deanonymization Setup
PII anonymization works in two phases:
- Inbound: Detect and replace PII with placeholders (e.g.,
<Person_0>,<Email_0>) - Outbound: Restore original PII values in the LLM response
Inbound Configuration
<inbound>
<!-- Enable PII Anonymization -->
<set-variable name="piiAnonymizationEnabled" value="true" />
<choose>
<when condition="@(context.Variables.GetValueOrDefault<string>("piiAnonymizationEnabled") == "true")">
<!-- Configure PII detection settings -->
<set-variable name="piiConfidenceThreshold" value="0.8" />
<set-variable name="piiEntityCategoryExclusions" value="PersonType" />
<set-variable name="piiDetectionLanguage" value="en" />
<!-- Optional: Configure custom regex patterns for additional PII detection -->
<set-variable name="piiRegexPatterns" value="@{
var patterns = new JArray {
new JObject {
["pattern"] = @"\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b",
["category"] = "CREDIT_CARD"
},
new JObject {
["pattern"] = @"\b[A-Z]{2}\d{6}[A-Z]\b",
["category"] = "PASSPORT_NUMBER"
}
};
return patterns.ToString();
}" />
<!-- Capture request body for PII processing -->
<set-variable name="piiInputContent" value="@(context.Request.Body.As<string>(preserveContent: true))" />
<!-- Apply PII anonymization -->
<include-fragment fragment-id="pii-anonymization" />
<!-- Replace request body with anonymized content -->
<set-body>@(context.Variables.GetValueOrDefault<string>("piiAnonymizedContent"))</set-body>
</when>
</choose>
</inbound>
Outbound Configuration
<outbound>
<!-- Store response body before processing -->
<set-variable name="responseBodyContent" value="@(context.Response.Body.As<string>(preserveContent: true))" />
<choose>
<when condition="@(context.Variables.GetValueOrDefault<string>("piiAnonymizationEnabled") == "true" &&
context.Variables.ContainsKey("piiMappings"))">
<!-- Set input for deanonymization -->
<set-variable name="piiDeanonymizeContentInput" value="@(context.Variables.GetValueOrDefault<string>("responseBodyContent"))" />
<!-- Apply PII deanonymization -->
<include-fragment fragment-id="pii-deanonymization" />
<!-- Optional: Enable PII processing audit logging to Event Hub -->
<set-variable name="piiStateSavingEnabled" value="true" />
<set-variable name="originalRequest" value="@(context.Variables.GetValueOrDefault<string>("piiInputContent"))" />
<set-variable name="originalResponse" value="@(context.Variables.GetValueOrDefault<string>("responseBodyContent"))" />
<include-fragment fragment-id="pii-state-saving" />
<!-- Replace response with deanonymized content -->
<set-body>@(context.Variables.GetValueOrDefault<string>("piiDeanonymizedContentOutput"))</set-body>
</when>
<otherwise>
<!-- Pass through original response -->
<set-body>@(context.Variables.GetValueOrDefault<string>("responseBodyContent"))</set-body>
</otherwise>
</choose>
</outbound>
PII Blocking (Request Rejection) Setup
Instead of anonymizing PII, some access contracts must reject any request that contains PII outright. This is useful for strict compliance scenarios where no PII — even in anonymized form — should reach the backend LLM.
Blocking reuses the pii-anonymization fragment purely for detection: the fragment populates the piiMappings variable with every PII entity it finds. If that collection contains any entries, the request is rejected with an HTTP 400 Bad Request before it is forwarded to the backend.
NOTE: Blocking does not require a dedicated detection fragment — it relies on the same
pii-anonymizationfragment used for masking. The difference is that the anonymized body is discarded and the request is short-circuited when PII is present.
Inbound Configuration
<inbound>
<base />
<!-- Enable PII Blocking -->
<set-variable name="piiBlockingEnabled" value="true" />
<choose>
<when condition="@(context.Variables.GetValueOrDefault<string>("piiBlockingEnabled") == "true")">
<!-- Configure PII detection settings -->
<set-variable name="piiAnonymizationEnabled" value="true" />
<set-variable name="piiConfidenceThreshold" value="0.75" />
<set-variable name="piiEntityCategoryExclusions" value="PersonType" />
<set-variable name="piiDetectionLanguage" value="en" />
<!-- Optional: custom regex patterns for domain-specific PII -->
<set-variable name="piiRegexPatterns" value="@{
var patterns = new JArray {
new JObject {
["pattern"] = @"\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b",
["category"] = "CREDIT_CARD"
},
new JObject {
["pattern"] = @"\b[A-Z]{2}\d{6}[A-Z]\b",
["category"] = "PASSPORT_NUMBER"
},
new JObject {
["pattern"] = @"\b784-\d{4}-\d{7}-\d{1}\b",
["category"] = "EMIRATES_ID"
}
};
return patterns.ToString();
}" />
<!-- Capture request body for PII processing -->
<set-variable name="piiInputContent" value="@(context.Request.Body.As<string>(preserveContent: true))" />
<!-- Run detection via the anonymization fragment (populates piiMappings) -->
<include-fragment fragment-id="pii-anonymization" />
<!-- Reject the request when any PII entity was detected -->
<choose>
<when condition="@{
var mappings = context.Variables.GetValueOrDefault<string>("piiMappings", "[]");
return JArray.Parse(mappings).Count > 0;
}">
<return-response>
<set-status code="400" reason="Bad Request" />
<set-header name="Content-Type" exists-action="override">
<value>application/json</value>
</set-header>
<set-body>@{
var mappings = JArray.Parse(context.Variables.GetValueOrDefault<string>("piiMappings", "[]"));
var categories = new HashSet<string>();
foreach (var mapping in mappings) {
var placeholder = mapping["placeholder"].ToString();
var category = placeholder.TrimStart('<').Split('_')[0];
categories.Add(category);
}
return new JObject(
new JProperty("error", new JObject(
new JProperty("code", "PII_DETECTED"),
new JProperty("message", "Request blocked: Personal Identifiable Information (PII) detected in the request."),
new JProperty("detectedCategories", string.Join(", ", categories)),
new JProperty("entityCount", mappings.Count)
))
).ToString();
}</set-body>
</return-response>
</when>
</choose>
</when>
</choose>
</inbound>
NOTE: When blocking is enabled you should not add the outbound
pii-deanonymizationstep — no anonymized content is ever forwarded to the backend, so there is nothing to restore.
Error Response Format:
When PII is detected, the request is rejected with an HTTP 400 Bad Request and a structured JSON error listing the detected categories:
{
"error": {
"code": "PII_DETECTED",
"message": "Request blocked: Personal Identifiable Information (PII) detected in the request.",
"detectedCategories": "Person, Email, PhoneNumber, InternationalBankingAccountNumber",
"entityCount": 4
}
}
Blocking vs. Anonymization:
| Behavior | Anonymization (piiAnonymizationEnabled) |
Blocking (piiBlockingEnabled) |
|---|---|---|
| PII detected in request | Replaced with placeholders and forwarded to backend | Request rejected with 400 Bad Request |
| Backend receives request | Yes (anonymized) | No |
| Outbound deanonymization | Required to restore original values | Not applicable |
| Use case | Protect PII while still allowing processing | Strict compliance — no PII may reach the backend |
NOTE: Use blocking when your compliance requirements prohibit any PII from being processed by backend services, even in anonymized form. A working reference implementation is available in the
compliance-piiblockingaccess contract.
Custom Regex Patterns
Extend Azure AI Language Service NLP detection with custom regex patterns for domain-specific PII:
<set-variable name="piiRegexPatterns" value="@{
var patterns = new JArray {
new JObject {
["pattern"] = @"\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b",
["category"] = "CREDIT_CARD"
},
new JObject {
["pattern"] = @"\b[A-Z]{2}\d{6}[A-Z]\b",
["category"] = "PASSPORT_NUMBER"
},
new JObject {
["pattern"] = @"\b\d{3}[-]?\d{4}[-]?\d{7}[-]?\d{1}\b",
["category"] = "NATIONAL_ID"
},
new JObject {
["pattern"] = @"\b784-\d{4}-\d{7}-\d{1}\b",
["category"] = "EMIRATES_ID"
}
};
return patterns.ToString();
}" />
TIP: Regex patterns are processed before calling Azure AI Language Service, allowing you to catch domain-specific patterns that NLP might miss.
Event Hub Logging
When piiStateSavingEnabled is set to "true", the pii-state-saving fragment logs detailed PII processing information to Event Hub for auditing and compliance purposes. The logged data includes:
- Operation metadata (timestamp, API name, product, subscription)
- Processing configuration (confidence threshold, exclusions)
- Entity counts and categories detected
- PII mappings (for detailed audit trails)
- Content length metrics
NOTE: For detailed implementation information and advanced scenarios, see PII Masking Guide.
Extending default policies
You can extend the out-of-the-box policies by leveraging APIM extensive policy expressions and capabilities.