Per-User Agent Auth: The Model Cannot Leak a Token It Never Held
AgentCore Identity keeps OAuth tokens out of the model by injecting them into your tool function at call time; the real failure mode is wiring an unverified user-id header from the browser.

AgentCore Identity injects credentials into your tool function at call time. Secrets stay out of the context window, if you assemble the plumbing correctly.
Your agent still authenticates as one shared service principal, so entitlements and audit collapse into "the agent did it." The model cannot leak a token it never held. AgentCore Identity injects credentials into your tool function at call time. Secrets stay out of the context window, if you assemble the plumbing correctly.
In this article: You will learn how AgentCore Identity separates workload identity from user identity, how
requires_access_tokeninjects OAuth credentials into plain Python without exposing them to the model, how the 3LO wire flow and token vault work, and which three gotchas (unverified user-id header, local vs deployed context, stale vault tokens) break production auth. By the end you will know how to give each analyst their own entitlements without putting a secret where a prompt injection can reach it.
Your multi-user agent still hits the market-data provider as one service principal: same token, same entitlements, every session is "the agent."
Entitlements become "we trust the agent." Audit becomes "the agent did it." Asked to prove Dana could not see Sam's licensed data, you shrug.
This is plumbing. It still holds the cleanest sentence in the series:
The model cannot leak a token it never held.
Secrets must never enter the context window. The agent needs an authenticated API, and you do not trust the agent. Naive solutions put the credential in the prompt, a tool argument, or a tool result. Whatever the model can see, a crafted page can ask it to repeat.
AgentCore Identity injects the credential into your Python function at call time as a keyword argument. Model asks for the tool; your code gets the token; the two never meet.
Three primitives

Workload identity. Not an IAM role or Cognito user: a separate identity with its own ARN for "who is the agent," independent of "who is the user." Auto-provisioned with AgentCore Runtime so credentials can bind to the pair (this agent, this user).
Token vault. OAuth tokens keyed by that pair. Default: AWS-managed KMS. If you need key ownership (financial services, healthcare, public sector), bind a customer-managed key at runtime creation time.
Inbound vs outbound. Inbound who may invoke: IAM SigV4 (service-to-service default) or JWT via OIDC (human path). Mutually exclusive per runtime; need both, use different runtime versions. Outbound what the agent may reach: 2LO (machine credentials as the agent) or 3LO (on behalf of a human).
Seat-licensed market data: JWT inbound, 3LO outbound. Dana gets Dana's entitlements.
The decorator
This listing configures 3LO OAuth for a market-data provider and injects the access token into a plain async function as a keyword argument at call time.
from bedrock_agentcore.identity import requires_access_token
@requires_access_token(
provider_name="market-data-provider",
scopes=["quotes.read", "filings.read"],
into="access_token", # injected as a kwarg ①
auth_flow="USER_FEDERATION", # 3LO ②
on_auth_url=lambda url: auth_url_holder.update({"url": url, "needs_auth": True}), # ③
callback_url="https://your-app.example.com/oauth/callback",
force_authentication=False, # cache the token ④
)
async def _fetch_quotes(symbol: str, access_token: str) -> str: # ⑤
async with httpx.AsyncClient() as http:
r = await http.get(
f"https://api.marketdata.example.com/v1/quotes/{symbol}",
headers={"Authorization": f"Bearer {access_token}"}, # ⑥
)
return r.text
① into names the keyword argument the decorator injects; no caller ever passes it.
② USER_FEDERATION selects the 3LO flow so the agent acts on behalf of a specific human.
③ On first consent the vault returns an auth URL; this callback surfaces it to the analyst.
④ With force authentication off, the vault reuses a cached token until it expires.
⑤ The signature advertises access_token, but only the decorator supplies it.
⑥ The token is used only inside the HTTP call and is never returned into model context.
Note: The full extracted listing at
code/agent-core/part-09-identity-per-user-auth/listings/01-requires-access-token.py
shows the imports and auth_url_holder stand-in elided here.
_fetch_quotes takes an access_token no caller ever passes. No secret in the tool schema or model context. Nothing for prompt injection to extract.

The agent-facing tool is a thin wrapper:
@tool
async def fetch_quotes(symbol: str) -> str:
"""Fetch current market quotes for a ticker symbol, as the requesting analyst."""
return await _fetch_quotes(symbol=symbol)
Siblings: requires_api_key for Secrets Manager; requires_iam_access_token for STS JWTs to internal services. Built-ins cover Google, GitHub, Slack, Salesforce, and Atlassian, plus custom OAuth 2.0 servers.
Both harnesses, identically
The wiring is the same for both frameworks; neither has an advantage.
Other AgentCore integrations are LangChain-shaped. Not here: requires_access_token decorates a plain async function. DeepAgents keeps LangChain @tool and tools=[...]. Claude Agent SDK swaps the import, returns {"content": [{"type": "text", "text": ...}]}, and registers via create_sdk_mcp_server. The decorated inner function is identical.
Identity sits at the function layer, a smaller contract than checkpointers and stores. Smaller contracts travel further.
Pattern check: mechanical enforcement on credentials, second only to disallowed_tools. The token is not in context. There is nothing to reveal.
What happens on the wire

- Analyst authenticates with your IdP and gets a JWT.
- App invokes the runtime with that JWT.
- Runtime validates issuer, signature, and expiry.
- Runtime exchanges it for a workload access token via
GetWorkloadAccessTokenForJWTand delivers it via payload header. - Tool fires; decorator calls the vault (
GetResourceOauth2Token) with that token. - First time only: vault returns an authorization URL;
on_auth_urlsurfaces it; analyst consents. - Provider token is stored bound to (workload identity, user identity), eliminating repeated consent until expiry.
- Later calls skip step 6.
The workload access token is AWS-signed, opaque, and first-party only. Runtime-managed agent identities cannot retrieve workload access tokens directly, so a hostile agent cannot extract them.
Refresh is automatic when the provider offers refresh tokens (access tokens typically one to two hours; refresh tokens about thirty days by default).
Gotcha: the impersonation header
Instead of a JWT you can pass X-Amzn-Bedrock-AgentCore-Runtime-User-Id and use GetWorkloadAccessTokenForUserId.
AgentCore does not verify that value. It is an opaque string the calling workload must get right.

If the front end forwards a browser userId into that header, any analyst can impersonate any other. AgentCore is doing what it documented; you wired an unauthenticated string into an auth decision.
Derive that value from authenticated principal context, never from a client-supplied parameter. Prefer JWT: GetWorkloadAccessTokenForJWT checks issuer, signature, and expiry. The header path is a promise from your front end.
AWS requires a separate IAM action, bedrock-agentcore:InvokeAgentRuntimeForUser, and scopes GetWorkloadAccessTokenForUserId to specific workload identities, never wildcards. Use the header only for pre-IdP development or a trusted upstream; otherwise use JWT.
Gotcha: not on your laptop
requires_access_token reads the workload access token from BedrockAgentCoreContext, populated from headers when deployed. Locally those headers are missing. Local runs cache workload identity in .agentcore.json; deployed, callback_url must match a registered URL or consent fails. Test auth on a deployed runtime.
Gotcha: vault tokens can be dead
Access tokens returned by AgentCore are not guaranteed to be valid. Users can revoke app access; the vault still returns a cached token the provider rejects.
Use force_authentication=True to re-federate. Treat third-party 401 as a permission outcome and re-authenticate. Permission is not transient; retrying a revoked token forever is a loop with a countdown.
The full chain

- Analyst to Runtime: JWT bearer, validated against your IdP.
- Runtime to Identity: workload access token, AWS-signed, first-party only.
- Runtime to Gateway: inbound JWT or IAM.
- Gateway to catalog API: outbound auth;
CALLER_IAM_CREDENTIALSpropagates the original caller so existing IAM policies apply. - Tool to market-data provider: analyst OAuth token from the vault, injected into your function.
No secret transits the model. Minimum identity per hop, no more.
Gateway also supports OAuth authorization-code flow through Identity for OAuth-protected MCP servers (MCP 2025-11-25+; older gateways grey it out).
Memory becomes per-analyst
AgentCoreMemorySaver and AgentCoreMemoryStore need an actor_id. Derive it from the validated JWT sub and memory is multi-tenant for free under (actor_id, thread_id).
Do not use the unverified user-id header. Cross-tenant memory poisoning is harder to spot than cross-tenant data access; nothing looks wrong for weeks.
Do this today
- Configure JWT inbound and one tool with
@requires_access_token(..., auth_flow="USER_FEDERATION")so the model never sees a provider token. - Match
callback_urlto a registered workload-identity URL; exercise first-consent and cached-token paths on a deployed runtime. - Audit for
X-Amzn-Bedrock-AgentCore-Runtime-User-Id. Derive only from authenticated principal context; prefer JWT when you have one. - On provider 401, re-authenticate with
force_authentication=Trueinstead of infinite retry of a vault-cached token. - Set memory
actor_idfrom the validated JWTsub.
Secrets stay out of the prompt
You now have an agent that acts as the invoking analyst on seat-licensed systems, with vault-bound credentials injected into functions never into prompts, and memory partitioned by a verified user.
Reach, compute, browse, remember, authenticate: the capability chain is complete. What remains: is any of it correct?
Observability and evaluation come next. The platform sells the judge. It does not sell the verdict. Keep the plumbing honest so this stays true: the model cannot leak a token it never held.