Apps and AI agents usually reach systems of record with a shared service-account key, so the secret spreads and no call traces to a person. This blueprint moves identity propagation into Omni/Flex Gateway policy: the caller presents only the Entra token it holds for the gateway's API, and the OAuth 2.0 OBO Credential Injection outbound policy exchanges it at Microsoft Entra ID for a backend-scoped token for the same user. Covers the two Entra app registrations, the outbound policy fields (microsoft-entra-obo flow, scope, distributed cache), inbound JWT validation, and a separate approval route that uses RFC 8693 token exchange with CIBA so high-consequence agent actions wait for a human (401 authorization_pending, Retry-After) and fail closed. The OBO path has been run end to end against Microsoft Graph; the CIBA route is documented, not yet run. Includes the gotchas: outbound-only policy, config wiped by inbound Save & Apply, versioned Graph paths, no binding on MCP instances.
Tags: omni-gateway, flex-gateway, microsoft-entra-id, oauth2, on-behalf-of, token-exchange, ciba, identity-propagation, jwt-validation, credential-injection, agent-governance, human-in-the-loop, api-security, cross-industry
Named-User Identity for Agent and App Calls: Entra On-Behalf-Of at the Gateway (CIBA Approval Route Documented, Not Yet Run)
Deliverable: An HTTP API instance on a Managed or Connected-mode Omni/Flex Gateway with the OAuth 2.0 OBO Credential Injection outbound policy, so a caller (app or AI agent) presents only the Entra token it already holds for the gateway's API, and the gateway exchanges it at Microsoft Entra ID for a token scoped to the backend, on behalf of the same user. The backend sees the named person, not a shared integration account, and the caller holds no backend credential. For actions a human must approve, a second route uses the policy's RFC 8693 token exchange with CIBA, so the backend token is issued only after the user approves out of band.
Objective: Apps and agents usually reach systems of record with a service-account key baked in. The secret spreads into every app, and every call looks like "the integration user", so a read or a control action can't be traced to a person. Regulated operations need that trace: utilities attribute access to named individuals, pipeline and plant operators attribute actions on assets, and finance attributes spend commitments to the approver. Passing the user's token straight through is not the fix either, because its audience is the gateway, not the backend. This blueprint puts identity propagation in gateway policy, so no service holds custom OAuth code and no agent holds a backend secret.
What's Getting Built
- Middle-tier API app registration (Microsoft Entra ID) — the identity the gateway exchanges for. Exposes the delegated scope
access_as_user under api://{api-app-id}, holds the only client secret, and has delegated permission on the backend.
- Client app registration (Entra ID) — the identity the caller signs in as. Delegated permission to
api://{api-app-id}/access_as_user, admin-consented.
- Identity-propagating route (HTTP API instance, Omni/Flex Gateway) — base path
/{route}, upstream = the backend, outbound policy OAuth 2.0 OBO Credential Injection with flow microsoft-entra-obo.
- Approval-gated route (second HTTP API instance) — same upstream for high-consequence operations only, outbound policy in flow
oauth2-token-exchange with Enable CIBA on, against an authorization server that implements the CIBA backchannel endpoint.
- Inbound controls (inbound policies on both instances) — JWT validation of the caller's Entra token (issuer, audience
api://{api-app-id}, expiry), plus rate limiting.
Prerequisites
- Entra ID: an admin who can register applications and grant admin consent (Application Developer to create apps, plus Global Administrator or Privileged Role Administrator to consent). A brand-new or unmanaged tenant must be claimed through a DNS TXT record before registration and consent work.
- Gateway: Omni/Flex Gateway 1.11.0 or later in Managed or Connected mode. The OBO policy is not supported in Local mode.
- Backend: an API that accepts Entra-issued access tokens for its own audience. To prove the exchange before wiring a real backend, use Microsoft Graph (
{graph}, scope {graph}/User.Read), which returns the signed-in user from /v1.0/me. In this blueprint {graph} is Graph's public HTTPS base (host graph.microsoft.com) and {login} is Entra's public HTTPS sign-in base (host login.microsoftonline.com). These two are fixed Microsoft hosts, not values you fill in.
- For the CIBA route: an authorization server that offers a CIBA backchannel authentication endpoint (
bc-authorize) and RFC 8693 token exchange, and a way to reach the approving user (push or app notification). Confirm your identity provider's CIBA support before you design around it.
- Tools: Azure CLI (
az) signed in to the tenant, and API Manager access to create instances and apply outbound policies.
Choose your path
- Identity propagation only (start here) — Choose this when the goal is "the backend sees the real user and the app holds no secret". One route, flow
microsoft-entra-obo.
- Add CIBA approval — Choose this when an agent may stage an action that a person must approve before it executes: a switching order, a setpoint change, a crew dispatch, a large spend commitment. Put those operations on a separate approval-gated route so ordinary reads never wait on a human.
- Non-Microsoft authorization server — Use flow
oauth2-token-exchange (RFC 8693) with Target Type, Target Value and Subject Token Type set for your server. The rest of the pattern is unchanged.
Architecture
1. Register the two Entra applications
- Create the middle-tier app:
az ad app create --display-name "{api-app-name}" --sign-in-audience AzureADMyOrg, then az ad app update --id {api-app-id} --identifier-uris "api://{api-app-id}".
- Expose a delegated scope named
access_as_user (type User) on it through PATCH {graph}/v1.0/applications/{api-app-object-id} with an api.oauth2PermissionScopes entry.
- Grant it delegated permission on the backend. For Graph that is
User.Read: az ad app permission add --id {api-app-id} --api {graph-app-id} --api-permissions {user-read-permission-id}=Scope, using Microsoft Graph's well-known app ID and the User.Read delegated permission ID from Microsoft's Graph permissions reference. For your own backend, add its delegated scope instead.
- Create a client secret on the middle-tier app (
az ad app credential reset --id {api-app-id}). It goes into the gateway policy form only, never into a repo, .env file or the caller.
- Create the client app with a public-client localhost redirect URI for CLI testing, set
requiredResourceAccess to the middle-tier scope, create service principals for both apps (az ad sp create), and run az ad app permission admin-consent on both. Consent can fail for a few seconds while service principals propagate; retry it.
2. Create the route and apply the OBO policy
- API Manager → Add API → HTTP API (a plain proxy, not an MCP server instance) on your gateway, base path
/{route}, upstream = the backend.
- The OBO policy is outbound: Policies → Add Outbound policy → select the upstream → OAuth 2.0 OBO Credential Injection → configure. It is not in the inbound policy list.
- Set Token Exchange Flow to
microsoft-entra-obo; the form defaults to oauth2-token-exchange. Client ID = the middle-tier app ID, Client Secret = its secret, Token Endpoint = {login}/{tenant-id}/oauth2/v2.0/token, Scope = the backend's delegated scope (required for Entra). Leave Target Type, Target Value, Requested Token Type and Subject Token Type blank (they apply to RFC 8693). Timeout defaults to 10000 ms.
- Turn Distributed on when the gateway runs more than one replica, so the exchanged-token cache is shared and replicas don't exchange the same token twice.
- Save and apply, then reopen the policy and confirm the fields persisted. If you apply the policy through the API instead, a later inbound Save & Apply in the UI can clear its configuration and every call returns
503; configure it through the outbound UI flow.
- The proxy passes the path through. For Graph, call
/{route}/v1.0/me; a bare /me returns 400 "Invalid version".
- The policy caches each exchanged backend token and reuses it while the same inbound token is presented, with a TTL that matches the token's expiry.
3. Validate the inbound token
- The policy's documentation doesn't say that it validates the caller's token, so don't rely on it. Add inbound JWT validation on the instance: issuer
{login}/{tenant-id}/v2.0, JWKS from the tenant's keys endpoint, audience api://{api-app-id}, and expiry. Reject anything else with 401 before an exchange is attempted.
- Require the
scp claim to include access_as_user, so a token for another API of the same tenant can't be replayed here.
- Add a rate limit per client app ID. Every new inbound token causes one token-endpoint call.
4. Gate high-consequence actions with CIBA
- Create a second HTTP API instance whose upstream paths are only the operations that need approval (for example
POST /switching-orders, POST /setpoints, POST /spend-commitments). Keep reads on the route from step 2.
- Apply the same outbound policy with Token Exchange Flow
oauth2-token-exchange, Enable CIBA on, CIBA Backchannel Endpoint = your authorization server's bc-authorize URL, Scope set (required for CIBA), Login Hint Claim = the inbound JWT claim that identifies the approver (default email; Entra tokens often carry preferred_username or upn instead, so check your tokens), and an optional Binding Message the approver sees.
- CIBA is available only with the RFC 8693 flow, not with
microsoft-entra-obo. Plan the approval route against an authorization server that implements both.
- While approval is pending, the gateway answers the caller with
401 Unauthorized, header Retry-After: 5, and body {"error":"authorization_pending", ...}. The caller (or the agent's tool runtime) must treat that as "waiting for a person", honor Retry-After, and retry the same request with the same inbound token. It must not count it as an auth failure or fall back to another credential.
- Fail closed: if approval is denied or the agent stops retrying, nothing reaches the backend. Give the agent a hard retry ceiling and have it report "not approved" to the user. Never let it retry through the read route or any path with broader rights.
- Turn Distributed on for multi-replica gateways so the CIBA request state is shared and the approver gets one prompt, not one per replica.
5. Security and compliance
- Secrets: the middle-tier client secret exists only in the gateway policy configuration. Rotate it on a schedule, and immediately if it was ever shown on screen or in a recording.
- Least privilege: grant the middle-tier app only the delegated backend scopes the route needs. OBO tokens can't exceed what the user has, so the user's own entitlements still apply at the backend.
- Audit: the backend logs the real user from the exchanged token. Log at the gateway: client app ID, user claim, route, upstream status, and CIBA outcome. Never log raw bearer tokens.
- MCP servers: in this setup, gateway-side credential injection does not bind on MCP server instances, which pass the caller's authorization through. Put OBO on the HTTP or LLM routes the agent's tools call, and govern the MCP instance with its own policies.
- Compliance mapping: this gives named-individual attribution at the system of record, which regimes such as NERC-CIP (utilities), API 1164 and IEC 62443 (pipelines and OT) and SOX (spend approvals) expect. Confirm the mapping with your compliance owner.
Sample payload — the two tokens, and a pending CIBA response
Decoded claims of the inbound token the caller holds, and of the token the gateway injects (Graph backend):
{
"inbound": { "aud": "api://{api-app-id}", "scp": "access_as_user", "upn": "{user}@{tenant-domain}" },
"outbound": { "aud": "{graph}", "scp": "User.Read", "upn": "{user}@{tenant-domain}" }
}
GET /{route}/v1.0/me through the gateway returns the signed-in user's own profile (displayName, userPrincipalName, mail, id). The caller never held a Graph credential.
Approval-gated route while the user hasn't approved yet:
HTTP/1.1 401 Unauthorized
Retry-After: 5
Content-Type: application/json
{"error":"authorization_pending","error_description":"CIBA authorization flow initiated. Downstream user authorization is in progress. Retry after the indicated interval."}
Deterministic checks
- A call with no token, a token for another audience, or an expired token returns
401 from inbound validation, and no token-endpoint call is made.
- A valid call reaches the backend with
aud = the backend and the same user claim as the inbound token, confirmed in the backend's log or by /v1.0/me.
- No configuration file, environment variable or caller-side code contains the middle-tier client secret.
- Repeating a call with the same inbound token within its lifetime makes no second exchange (the cache is hit).
- Two different users calling the same route reach the backend as two different identities.
- On the approval route, the first call returns
401 authorization_pending with Retry-After; nothing reaches the backend until approval; after approval the retried call succeeds as the same user.
- A denied or abandoned approval never reaches the backend, and the agent reports it rather than retrying elsewhere.
Testing and validation
- Prove the exchange first against Microsoft Graph, which tells you unambiguously whose token arrived. Sign in with
az login --scope api://{api-app-id}/access_as_user --allow-no-subscriptions, get the token with az account get-access-token --scope api://{api-app-id}/access_as_user, call /{route}/v1.0/me, and compare the inbound aud with the user Graph returns. This path has been run end to end against a live Entra tenant and a managed gateway.
- Then swap the upstream for your backend, or an echo service that reflects the injected token's
aud and subject, and repeat. Backend audience plus original subject is the pass condition.
- For recordings or shared screens, sign in as a dedicated demo user and show decoded claims, never raw JWTs.
- The CIBA route has not been run in this setup. Its fields and the pending response above come from the policy documentation. Prove it in a non-production tenant with your authorization server first, including denial, timeout and two replicas, before any agent depends on it.
Phased rollout
Phase 1 — Prove the exchange
- Register both apps, create a route to Graph, apply OBO, add inbound JWT validation, and verify the two-token result.
Phase 2 — Real backend, reads
- Point the route at one system of record that accepts Entra tokens. Move one app or agent's read traffic off its service account and remove that key.
Phase 3 — Approvals and scale
- Stand up the approval-gated route for one high-consequence operation, prove CIBA approve, deny and timeout, enable Distributed, then move the remaining service-account integrations route by route.
Success metrics
- Share of app and agent calls to systems of record that carry a named user instead of a shared account (target: all in-scope routes).
- Backend credentials remaining in app or agent configuration (target: zero).
- High-consequence agent actions executed without a recorded human approval (target: zero).
- Token-endpoint calls per request, which shows whether the cache is working.
Troubleshooting
503 on every call after an edit — Likely cause: the outbound policy's configuration was cleared by an inbound UI Save & Apply after it was applied through the API. Fix: reconfigure it through Add Outbound policy and reopen it to confirm the fields persisted.
- OBO policy not in the policy list — Likely cause: looking in the inbound list. Fix: use Policies → Add Outbound policy and select the upstream.
- Exchange fails right after setup — Likely cause: the flow is still
oauth2-token-exchange, or Scope is empty. Fix: set microsoft-entra-obo and the backend's delegated scope.
- Graph returns
400 "Invalid version" — Likely cause: calling /{route}/me. Fix: call /{route}/v1.0/me.
- App registration or consent refused — Likely cause: missing Entra roles, or an unmanaged tenant. Fix: get Application Developer plus a consenting admin; claim the tenant through DNS first.
- Backend still sees the integration user — Likely cause: the caller is sending its old service-account header, or the backend ignores the bearer token. Fix: remove the old credential from the caller and confirm the backend authorizes on the token's subject.
- CIBA approver never gets a prompt — Likely cause: Login Hint Claim names a claim the inbound token doesn't carry. Fix: decode a real inbound token and set the claim it has (
email, preferred_username or upn).
- Approver gets duplicate prompts — Likely cause: several replicas without Distributed. Fix: enable Distributed.