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.

Rick Hightower

Cover image for “Per-User Agent Auth: The Model Cannot Leak a Token It Never Held” by Rick Hightower

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_token injects 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

AgentCore Identity rests on workload identity, a token vault keyed by agent-plus-user, and a clean inbound versus outbound auth split.

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 model only sees the tool name and args; the decorator pulls the token from the vault and injects it into the function so the two never meet.

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

The 3LO path: JWT inbound, workload access token exchange, vault lookup, first-time consent, then provider API calls with the analyst token.

  1. Analyst authenticates with your IdP and gets a JWT.
  2. App invokes the runtime with that JWT.
  3. Runtime validates issuer, signature, and expiry.
  4. Runtime exchanges it for a workload access token via GetWorkloadAccessTokenForJWT and delivers it via payload header.
  5. Tool fires; decorator calls the vault (GetResourceOauth2Token) with that token.
  6. First time only: vault returns an authorization URL; on_auth_url surfaces it; analyst consents.
  7. Provider token is stored bound to (workload identity, user identity), eliminating repeated consent until expiry.
  8. 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.

JWT inbound gives cryptographic proof; the User-Id header is opaque, so a client-supplied value becomes an impersonation primitive.

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

Every hop carries only the minimum identity it needs: analyst JWT, workload token, Gateway auth, caller IAM, then the analyst OAuth token from the vault.

  • 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_CREDENTIALS propagates 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_url to 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=True instead of infinite retry of a vault-cached token.
  • Set memory actor_id from the validated JWT sub.

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.