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:
- Authenticates users through Microsoft Entra ID (the company's SSO)
- Uses AgentCore Identity's native OBO exchange to swap the user's token for a ServiceNow-scoped token
- Calls the ServiceNow REST API carrying the user's delegated identity
- 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_EXCHANGEflow is supported at the API level (GetResourceOauth2Tokenwithoauth2Flow=ON_BEHALF_OF_TOKEN_EXCHANGE), but the Python SDK's@requires_access_tokendecorator still only accepts"M2M"and"USER_FEDERATION"asauth_flowvalues. Until the SDK catches up, you'll need to call theIdentityClientdirectly 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.
- In the Azure Portal → Entra ID → App registrations → New registration
- Name it something like
ServiceNow API Connector - After creation, note the Application (client) ID — this is your
API_CONNECTOR_CLIENT_ID - Go to Expose an API → Set the Application ID URI (e.g.,
api://{API_CONNECTOR_CLIENT_ID}) -
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.
-
App registrations → New registration → name it
ServiceNow Agent - Note the Application (client) ID — this is your
AGENT_APP_CLIENT_ID - Go to Certificates & secrets → New client secret → save the secret value
- Go to API permissions → Add a permission → My APIs → select
ServiceNow API Connector→ checkuser_impersonation→ Add permissions - 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.
-
Create an Inbound Integration
- In ServiceNow admin: System OAuth → Inbound Integrations → New
- Set the Client ID to your
API_CONNECTOR_CLIENT_IDfrom Step 1a
-
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_username→Emailfield in ServiceNow - Uncheck JTI verification if your Entra ID tenant doesn't include it in tokens
-
Create an Auth Scope
- Name it (e.g.,
APIConnector) - Limit the scope to the Table API — least privilege
- Name it (e.g.,
-
Verify user provisioning
- Entra ID users need matching
sys_userrecords in ServiceNow - The
emailfield insys_usermust match thepreferred_usernameclaim from Entra ID
- Entra ID users need matching
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_EXCHANGEto the decorator, thefetch_snow_tokenfunction 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:
- On-behalf-of token exchange with AgentCore Identity — AWS documentation for the OBO feature
- On-behalf-of flow token exchange with AgentCore Gateway — Greg Biegel's walkthrough covering the ServiceNow inbound integration setup
- AgentCore Identity samples — EntraID examples
- AgentCore Identity SDK reference
This article was originally published by DEV Community and written by Sumanth P.
Read original article on DEV Community