Technology May 01, 2026 · 13 min read

Building an Agent that respects User Permissions — With AWS Bedrock AgentCore and Entra ID

A practical guide to building an AI agent that queries ServiceNow as the actual user, not a service account, using AgentCore Identity's On-Behalf-Of token exchange. The Problem Nobody Talks About Everyone's building AI agents that talk to enterprise systems. But here's the thing most...

DE
DEV Community
by Sumanth P
Building an Agent that respects User Permissions — With AWS Bedrock AgentCore and Entra ID

A practical guide to building an AI agent that queries ServiceNow as the actual user, not a service account, using AgentCore Identity's On-Behalf-Of token exchange.

The Problem Nobody Talks About

Everyone's building AI agents that talk to enterprise systems. But here's the thing most demos skip over: security.

Picture this. You build an agent that helps employees interact with ServiceNow. Jane asks: "Show me 5 incidents assigned to me." Your agent dutifully queries ServiceNow using a service account, filters by Jane's name, and returns results. Looks great in the demo.

Except that service account can see everything — HR complaints, security investigations, executive escalations. If the LLM gets creative with a query, or someone crafts a clever prompt injection, your agent could surface data Jane was never supposed to see. And when the security team checks the audit trail? All they find is "service-account-bot" made the request. Not helpful.

The fix is straightforward in concept: the agent should access ServiceNow as Jane, with Jane's exact permissions. If she can't see HR incidents when she logs into ServiceNow directly, the agent shouldn't see them either.

That's what the On-Behalf-Of (OBO) token exchange pattern does. And AWS recently added native support for it in Bedrock AgentCore Identity.

What We're Building

A Strands-based AI agent running on AgentCore Runtime that:

  1. Authenticates users through Microsoft Entra ID (the company's SSO)
  2. Uses AgentCore Identity's native OBO exchange to swap the user's token for a ServiceNow-scoped token
  3. Calls the ServiceNow REST API carrying the user's delegated identity
  4. Returns only what ServiceNow's ACLs allow that specific user to see

No service accounts. No broad permissions. No custom token exchange code.

The Architecture

The end-to-end flow: user authenticates via Entra ID, AgentCore validates the JWT, AgentCore Identity performs the OBO exchange, and the agent calls ServiceNow with the user's delegated token.

Two tokens are in play:

  • Token A: Issued by Entra ID when Jane logs in. Scoped to the Agent App. Proves Jane's identity but doesn't grant access to ServiceNow.
  • Token B: Issued by Entra ID through the OBO exchange. Scoped to ServiceNow. Still carries Jane's identity. This is what ServiceNow sees.

The agent never holds a service account credential. It never sees data Jane isn't allowed to see. And ServiceNow's audit log shows jane.smith@company.com made the query — not a bot.

How AgentCore Identity OBO Works

Before this feature, you had two options for outbound auth in AgentCore Identity:

  • M2M (client credentials): The agent authenticates as itself. No user identity. ServiceNow sees "the bot."
  • USER_FEDERATION (3-legged OAuth): The user gets redirected to a browser to consent. Preserves identity but requires interactive login.

Neither worked for OBO, where you need to silently exchange an existing user token for a downstream-scoped token — no browser redirect, no consent prompt.

AgentCore Identity now supports a third option: ON_BEHALF_OF_TOKEN_EXCHANGE. When you configure a credential provider with JWT_AUTHORIZATION_GRANT, it sends the inbound JWT as an assertion with grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer. That's exactly what Entra ID's OBO endpoint expects.

The service handles the heavy lifting:

  • Takes the inbound JWT (Token A) from the agent
  • Combines it with the client credentials stored in the credential provider
  • Sends the exchange request to Entra ID's token endpoint
  • Caches the resulting Token B in the Token Vault
  • Returns Token B to the agent

You don't touch the inbound token or manage client secrets in your code.

Important caveat: At the time of writing, the ON_BEHALF_OF_TOKEN_EXCHANGE flow is supported at the API level (GetResourceOauth2Token with oauth2Flow=ON_BEHALF_OF_TOKEN_EXCHANGE), but the Python SDK's @requires_access_token decorator still only accepts "M2M" and "USER_FEDERATION" as auth_flow values. Until the SDK catches up, you'll need to call the IdentityClient directly or use the AWS CLI/boto3 data plane API. The code examples below show both approaches.

Step-by-Step Implementation

Prerequisites

  • A Microsoft Entra ID tenant you can administer
  • A ServiceNow instance with admin access to configure OAuth inbound integrations
  • AWS account with Bedrock model access (Claude Sonnet or Haiku)
  • AgentCore CLI: pip install bedrock-agentcore-starter-toolkit

Part 1: Entra ID and ServiceNow Configuration

Before touching any AWS or agent code, you need to wire up the identity chain between Entra ID and ServiceNow. This involves two Entra ID app registrations and an inbound integration in ServiceNow. (Credit to Greg Biegel's walkthrough for documenting the ServiceNow side of this setup.)

Step 1a: Create the ServiceNow API Connector App Registration (Entra ID)

This app registration represents ServiceNow as a resource in Entra ID. Its client ID becomes the audience claim in Token B.

  1. In the Azure PortalEntra IDApp registrationsNew registration
  2. Name it something like ServiceNow API Connector
  3. After creation, note the Application (client) ID — this is your API_CONNECTOR_CLIENT_ID
  4. Go to Expose an APISet the Application ID URI (e.g., api://{API_CONNECTOR_CLIENT_ID})
  5. Add a scope → name it user_impersonation, set "Who can consent" to "Admins and users", and enable it

Step 1b: Create the Agent App Registration (Entra ID)

This app registration represents your AI agent. Its client ID becomes the audience claim in Token A.

  1. App registrationsNew registration → name it ServiceNow Agent
  2. Note the Application (client) ID — this is your AGENT_APP_CLIENT_ID
  3. Go to Certificates & secretsNew client secret → save the secret value
  4. Go to API permissionsAdd a permissionMy APIs → select ServiceNow API Connector → check user_impersonationAdd permissions
  5. Click Grant admin consent for your tenant

This authorizes the Agent App to perform OBO exchanges for the ServiceNow API Connector scope.

Step 1c: Configure ServiceNow to Accept Entra ID Tokens

This is the part most tutorials skip. Without it, ServiceNow won't know what to do with Token B.

  1. Create an Inbound Integration

    • In ServiceNow admin: System OAuthInbound IntegrationsNew
    • Set the Client ID to your API_CONNECTOR_CLIENT_ID from Step 1a
  2. Create an OIDC Provider Configuration

    • Choose to create a new OIDC provider
    • Set the OIDC Metadata URL to:
     https://login.microsoftonline.com/{TENANT_ID}/v2.0/.well-known/openid-configuration
    
  • Map the user claim: preferred_usernameEmail field in ServiceNow
  • Uncheck JTI verification if your Entra ID tenant doesn't include it in tokens
  1. Create an Auth Scope

    • Name it (e.g., APIConnector)
    • Limit the scope to the Table API — least privilege
  2. Verify user provisioning

    • Entra ID users need matching sys_user records in ServiceNow
    • The email field in sys_user must match the preferred_username claim from Entra ID

Part 2: AWS AgentCore Configuration

Step 2a: Create the OAuth2 Credential Provider

This registers Entra ID as the OAuth provider and configures the OBO exchange. The clientId and clientSecret come from the Agent App Registration (Step 1b):

aws bedrock-agentcore-control create-oauth2-credential-provider \
  --cli-input-json '{
    "name": "EntraIdServiceNow",
    "credentialProviderVendor": "CustomOauth2",
    "oauth2ProviderConfigInput": {
      "customOauth2ProviderConfig": {
        "oauthDiscovery": {
          "discoveryUrl": "https://login.microsoftonline.com/YOUR_TENANT_ID/v2.0/.well-known/openid-configuration"
        },
        "clientId": "YOUR_AGENT_APP_CLIENT_ID",
        "clientSecret": "YOUR_AGENT_APP_CLIENT_SECRET",
        "onBehalfOfTokenExchangeConfig": {
          "grantType": "JWT_AUTHORIZATION_GRANT"
        }
      }
    }
  }'

This single command stores the client secret securely, configures the jwt-bearer grant type, and sets up the Token Vault for caching.

Step 2b: Create a Workload Identity

aws bedrock-agentcore-control create-workload-identity \
  --name "servicenow-agent-workload"

Step 2c: Create the Agent Project

agentcore create --non-interactive \
  --project-name ServiceNowAgent \
  --template basic \
  --agent-framework Strands \
  --model-provider Bedrock

cd ServiceNowAgent

Part 3: Write and Deploy the Agent

Step 3a: Write the Agent Code

Replace the generated src/main.py. Since the @requires_access_token decorator doesn't yet support ON_BEHALF_OF_TOKEN_EXCHANGE as an auth_flow value, we call the IdentityClient directly:

"""
ServiceNow Agent with AgentCore Identity OBO.

Inbound:  Custom JWT Authorizer (Entra ID)
Outbound: ON_BEHALF_OF_TOKEN_EXCHANGE via IdentityClient
"""

import os
import contextvars
import json
import logging

import httpx
from strands import Agent, tool
from strands.models import BedrockModel
from bedrock_agentcore.runtime import BedrockAgentCoreApp, BedrockAgentCoreContext
from bedrock_agentcore.services.identity import IdentityClient

app = BedrockAgentCoreApp()
log = logging.getLogger(__name__)

SNOW_INSTANCE_URL = os.environ.get("SNOW_INSTANCE_URL", "")
MODEL_ID = os.environ.get("MODEL_ID", "us.anthropic.claude-sonnet-4-20250514-v1:0")
AWS_REGION = os.environ.get("AWS_REGION", "us-east-1")

# Request-scoped token storage
snow_token_var: contextvars.ContextVar[str] = contextvars.ContextVar(
    "snow_token", default=""
)

identity_client = IdentityClient(region=AWS_REGION)


async def fetch_snow_token() -> None:
    """
    Perform OBO exchange via AgentCore Identity.

    Gets the workload access token (which wraps the inbound JWT),
    then calls GetResourceOauth2Token with ON_BEHALF_OF_TOKEN_EXCHANGE.
    """
    workload_token = BedrockAgentCoreContext.get_workload_access_token()
    if not workload_token:
        raise RuntimeError("No workload access token. Is inbound JWT auth configured?")

    token_b = await identity_client.get_token(
        provider_name="EntraIdServiceNow",
        scopes=["api://YOUR_API_CONNECTOR_CLIENT_ID/user_impersonation"],
        agent_identity_token=workload_token,
        auth_flow="ON_BEHALF_OF_TOKEN_EXCHANGE",
    )
    snow_token_var.set(token_b)


# -- ServiceNow REST client --

def snow_request(method, endpoint, params=None, json_body=None):
    token = snow_token_var.get()
    if not token:
        return {"error": "No ServiceNow token available."}
    try:
        resp = httpx.request(
            method,
            f"{SNOW_INSTANCE_URL}/api/now/{endpoint}",
            headers={
                "Authorization": f"Bearer {token}",
                "Content-Type": "application/json",
                "Accept": "application/json",
            },
            params=params,
            json=json_body,
            timeout=30,
        )
        resp.raise_for_status()
        return resp.json()
    except httpx.HTTPStatusError as exc:
        status = exc.response.status_code
        if status == 401:
            return {"error": "Token expired. Please re-authenticate."}
        if status == 403:
            return {"error": "Access denied for this operation."}
        return {"error": f"ServiceNow error: {status}"}
    except Exception as exc:
        return {"error": str(exc)}


# -- Tools --

@tool
def query_incidents(
    query: str = "",
    limit: int = 10,
    fields: str = "number,short_description,priority,state,assigned_to",
) -> str:
    """Search for incidents in ServiceNow."""
    return json.dumps(snow_request("GET", "table/incident", params={
        "sysparm_query": query,
        "sysparm_limit": limit,
        "sysparm_fields": fields,
    }), indent=2)


@tool
def get_incident(number: str) -> str:
    """Get details of a specific incident by number."""
    return json.dumps(snow_request("GET", "table/incident", params={
        "sysparm_query": f"number={number}",
        "sysparm_limit": 1,
    }), indent=2)


@tool
def update_incident(sys_id: str, updates: dict) -> str:
    """Update an existing incident."""
    return json.dumps(
        snow_request("PATCH", f"table/incident/{sys_id}", json_body=updates),
        indent=2,
    )


# -- Agent entrypoint --

@app.entrypoint
async def invoke(payload, context):
    try:
        await fetch_snow_token()
    except Exception as exc:
        log.error(f"OBO exchange failed: {exc}")
        yield "Authentication error: could not obtain ServiceNow token."
        return

    agent = Agent(
        model=BedrockModel(model_id=MODEL_ID),
        tools=[query_incidents, get_incident, update_incident],
        system_prompt=(
            "You are a ServiceNow assistant. You help users query and manage "
            "their incidents. You only see records the user is authorized to "
            "access. Be concise."
        ),
    )

    stream = agent.stream_async(payload.get("prompt"))
    async for event in stream:
        if "data" in event and isinstance(event["data"], str):
            yield event["data"]


if __name__ == "__main__":
    app.run()

When the SDK adds ON_BEHALF_OF_TOKEN_EXCHANGE to the decorator, the fetch_snow_token function collapses to five lines with @requires_access_token. The API-level support is already there — it's just the Python decorator that hasn't caught up yet.

Step 3b: Deploy

agentcore configure --entrypoint src/main.py --non-interactive

agentcore deploy -y \
  --env SNOW_INSTANCE_URL=https://YOUR_INSTANCE.service-now.com \
  --env MODEL_ID=us.anthropic.claude-sonnet-4-20250514-v1:0

Step 3c: Configure Inbound JWT Authorizer

aws bedrock-agentcore update-agent-runtime \
  --agent-runtime-id YOUR_RUNTIME_ID \
  --authorizer-configuration '{
    "customJWTAuthorizer": {
      "discoveryUrl": "https://login.microsoftonline.com/YOUR_TENANT_ID/v2.0/.well-known/openid-configuration",
      "allowedClients": ["YOUR_AGENT_APP_CLIENT_ID"],
      "allowedAudiences": ["api://YOUR_AGENT_APP_CLIENT_ID"]
    }
  }'

Step 3d: Update IAM Permissions

aws iam put-role-policy \
  --role-name YOUR_AGENT_EXECUTION_ROLE \
  --policy-name AgentCoreIdentityOBO \
  --policy-document '{
    "Version": "2012-10-17",
    "Statement": [{
      "Effect": "Allow",
      "Action": [
        "bedrock-agentcore:GetResourceOauth2Token",
        "bedrock-agentcore:GetWorkloadAccessTokenForJwt",
        "secretsmanager:GetSecretValue"
      ],
      "Resource": [
        "arn:aws:bedrock-agentcore:REGION:ACCOUNT:token-vault/default/oauth2credentialprovider/EntraIdServiceNow",
        "arn:aws:bedrock-agentcore:REGION:ACCOUNT:workload-identity-directory/default/workload-identity/*",
        "arn:aws:bedrock-agentcore:REGION:ACCOUNT:workload-identity-directory/default",
        "arn:aws:bedrock-agentcore:REGION:ACCOUNT:token-vault/default",
        "arn:aws:secretsmanager:REGION:ACCOUNT:secret:bedrock-agentcore-identity!default/oauth2/EntraIdServiceNow*"
      ]
    }]
  }'

Step 3e: Test

# Without a token — rejected before agent code runs
agentcore invoke '{"prompt": "Show me my incidents"}'
# Expected: AccessDeniedException

# With a valid Entra ID token
agentcore invoke \
  --bearer-token "YOUR_ENTRA_ID_TOKEN" \
  --user-id "jane.smith@company.com" \
  '{"prompt": "Show me 5 incidents assigned to me"}'

What Jane sees:

Incident Description Priority State
INC0012345 VPN connection dropping P2 In Progress
INC0012389 Outlook calendar sync failure P3 New
INC0012401 Laptop BSOD after Windows update P2 In Progress
INC0012455 Shared drive permission error P3 New
INC0012478 Zoom plugin not loading in Teams P4 Assigned

What Jane does not see (blocked by ServiceNow ACLs):

  • INC0012350 — Employee complaint (HR category)
  • INC0012399 — SOC alert (Security category)

These records exist but are invisible to Jane. The agent never receives them — ServiceNow filters before the response leaves the instance.

Tracing the Request

Hop 1: Jane → AgentCore Runtime
The Chat UI attaches Jane's Entra ID token (Token A) as a Bearer header. AgentCore's Custom JWT Authorizer validates it against Entra ID's JWKS endpoint. Invalid tokens are rejected before any agent code runs.

Hop 2: Agent → AgentCore Identity → Entra ID
The agent calls IdentityClient.get_token() with auth_flow="ON_BEHALF_OF_TOKEN_EXCHANGE". AgentCore Identity takes Token A, combines it with the stored client credentials, and sends a jwt-bearer grant to Entra ID. Entra ID returns Token B — same user, different audience.

Hop 3: Agent → ServiceNow
The query_incidents tool calls ServiceNow's REST API with Authorization: Bearer <Token B>. ServiceNow decodes Token B, matches jane.smith@company.com to a sys_user record, and applies her ACLs. Only her authorized incidents come back.

Hop 4: Back to Jane
The LLM formats the results. Jane sees her 5 incidents. The restricted ones never left ServiceNow.

What This Replaces

Before native OBO support, implementing this pattern meant writing roughly 200 lines of custom code: a Starlette middleware to intercept requests, a function to POST to Entra ID's token endpoint, an in-memory cache with TTL management, Secrets Manager integration for the client secret, and JWT parsing for cache keying.

With native OBO, the credential provider configuration handles the protocol and secrets. The agent code just calls get_token() with the right flow type. When the SDK decorator catches up, it'll be even simpler.

Where Else This Works

OBO isn't specific to ServiceNow. Any downstream API that accepts delegated user tokens works the same way:

  • Microsoft Graph — access email, calendar, or OneDrive as the user
  • Salesforce — query CRM records with the user's permissions
  • Internal APIs — anything behind your Entra ID tenant with JWT auth
  • Multi-hop chains — Agent A calls Agent B calls ServiceNow, all carrying the original user's identity

The requirement is that the downstream service accepts tokens from your IdP and enforces authorization based on the subject claim.

Wrapping Up

Building AI agents that access enterprise data is the easy part. Making sure they respect the same access controls as the humans they serve — that's where it gets interesting.

AgentCore Identity's OBO support takes most of the plumbing off your plate. You configure a credential provider, call the API in your agent, and the platform handles the token exchange and caching. Your agent code stays focused on what matters: helping people get things done.

If you're building agents that touch user-specific data in enterprise systems, bake this pattern in from the start. Retrofitting security after the fact is never fun.

References:

DE
Source

This article was originally published by DEV Community and written by Sumanth P.

Read original article on DEV Community
Back to Discover

Reading List