Agentic AI AcademyAgentic AI Academy

Outbound Auth & the Token Vault

The workload-access-token flow, 2LO vs. 3LO, refresh tokens

Intermediate 14 minBuilder
What you'll be able to do
  • Trace the Workload Access Token flow from inbound authorize through token exchange, vault call, user consent, and cached storage
  • Distinguish 2LO (Client Credentials / M2M) from 3LO (Authorization Code / user-delegated) and pick the right `auth_flow`
  • Configure refresh tokens correctly per provider (Google, Microsoft, GitHub, Slack) and recover from a failed token with `force_authentication`
  • Explain how the token vault isolates credentials per-(agent identity + user id) and encrypts them with KMS
  • Apply the right IAM for the workload token across the pre- and post-Oct-13-2025 service-linked-role boundary
At a glance

Outbound auth is how an AgentCore agent calls Google Drive, GitHub, Slack, or an internal API on a user's behalf without ever putting a secret in code, config, or the LLM context. This lesson walks the full Workload Access Token flow end to end, distinguishes 2LO (M2M) from 3LO (user-delegated) and their per-provider refresh-token gotchas, and explains how the token vault enforces strict per-(agent + user) isolation with KMS encryption.

  1. 1The problem: an agent that calls other people's APIs
  2. 2The Workload Access Token flow, end to end
  3. 32LO vs 3LO: as itself, or on behalf of a user
  4. 4Per-provider refresh-token gotchas
  5. 5The token vault: what it holds and how it isolates
  6. 6IAM for the workload token: the Oct-13-2025 boundary

The problem: an agent that calls other people's APIs

Your agent needs to read the user's Google Drive, open a GitHub issue, or post to a Slack channel. The naive approaches all fail in production:

  • Hard-code an API key. Now every user shares one identity, the secret lives in your image, and it leaks into logs and the LLM context the moment something gets printed.
  • Pass the user's OAuth token in the prompt. The token is now in plaintext in the model's context window — exfiltratable by prompt injection, logged by observability, and impossible to scope or revoke per call.
  • Ask the user to paste credentials. Consent fatigue, no refresh, and you are storing other people's tokens yourself.

Outbound Auth (the egress side of AgentCore Identity) solves this. The agent never holds a long-lived secret. Instead it proves who it is acting for, and AgentCore's token vault hands back a short-lived downstream token — fetched, refreshed, and isolated by the service. The raw token is injected as a function argument, not into the model.

Teaching framing from the dossier: Inbound = the bouncer at the door. Outbound = the valet who fetches the user's keys from a locked vault. Identity makes agentic auth delegated, not impersonated-with-a-shared-secret.

Two problems get solved at once: the agent reaches downstream resources (Google Drive, GitHub, Slack, internal APIs, other MCP servers) on behalf of the user (3LO) or as itself (2LO/M2M), and it does so without any secret in code or in LLM context.

Key insight

Delegated, not impersonated

The whole design goal is that the agent acts with the user's delegated authority and a short-lived token, instead of as the user with a shared secret. That is what lets you scope, audit (CloudTrail), revoke, and isolate per user — none of which a baked-in API key gives you.

The Workload Access Token flow, end to end

A Workload Identity is auto-created with every runtime (ARN shape arn:aws:bedrock-agentcore:<region>:<account>:workload-identity/directory/default/workload-identity/<agent-name>). It is the agent's first-class identity — the thing the vault keys credentials against. The end-to-end outbound flow has five steps:

  1. Inbound: the Runtime authorizes the inbound token (a JWT, or SigV4 for IAM callers). This is the upstream step covered in the inbound-auth lesson.
  2. Exchange: the Runtime exchanges that inbound token for a Workload Access Token via bedrock-agentcore:GetWorkloadAccessTokenForJWT (JWT path — it derives user identity from the ISS/SUB claims). The token is delivered in the payload header WorkloadAccessToken. (SigV4 callers use GetWorkloadAccessToken; the opaque user-id header path uses GetWorkloadAccessTokenForUserId.)
  3. Outbound: the agent uses that Workload Access Token to call the token vault via bedrock-agentcore:GetResourceOauth2Token. For 3LO, the vault generates the provider's authorization URL.
  4. Consent: the agent surfaces the URL (via the on_auth_url callback); the user grants consent in their browser.
  5. Storage: Identity caches the downstream token in the vault, keyed by (agent workload identity + user id), so future calls skip the consent step until the token expires.

Under the hood the decorator orchestrates three APIs: CreateWorkloadIdentity, GetWorkloadAccessToken, and GetResourceOauth2Token. You rarely call them directly — the SDK decorator runs the whole sequence and injects the resulting token as a kwarg:

python
from bedrock_agentcore.identity.auth import requires_access_token

@requires_access_token(
    provider_name="google-provider",
    scopes=["https://www.googleapis.com/auth/drive.metadata.readonly"],
    auth_flow="USER_FEDERATION",                 # 3LO — user-delegated
    on_auth_url=lambda url: print("Authorize:\n" + url),
    force_authentication=False,
    callback_url="<oauth2_callback_url>",
)
async def read_from_google_drive(*, access_token: str):
    # access_token is injected here — it never enters the model's context
    ...

The key property: the raw token is delivered straight into your business logic as the access_token argument and never touches the LLM context.

Note

Step 2 picks the API by how the caller authenticated

JWT inbound -> GetWorkloadAccessTokenForJWT (user identity from ISS/SUB). SigV4 inbound -> GetWorkloadAccessToken. The opaque X-Amzn-Bedrock-AgentCore-Runtime-User-Id header path -> GetWorkloadAccessTokenForUserId. They all produce the same WorkloadAccessToken that step 3 uses against the vault.

Tip

Don't print the consent URL in production

The example uses on_auth_url=lambda url: print(...) for clarity. In a real runtime you should stream the consent URL back to the calling client (e.g. as part of your response payload), not write it to stdout where no user will see it.

2LO vs 3LO: as itself, or on behalf of a user

Outbound auth comes in exactly two legged-OAuth shapes, and the only knob you flip is the decorator's auth_flow:

2LO3LO
NameClient Credentials / M2MAuthorization Code
Who the agent acts asItselfOn behalf of a consenting user
SDK valueauth_flow="M2M"auth_flow="USER_FEDERATION"
User consent URL?NoYes (via on_auth_url)
Typical useService-to-service, an internal API the agent ownsGoogle Drive / GitHub / Slack as the end user

2LO (Client Credentials / M2M). The agent authenticates as itself — there is no end user in the loop. Pick this for machine-to-machine calls where the downstream resource trusts the agent's own identity.

python
@requires_access_token(
    provider_name="internal-api-provider",
    scopes=["orders:read"],
    auth_flow="M2M",                 # 2LO — agent acts as itself, no consent URL
)
async def fetch_orders(*, access_token: str):
    ...

3LO (Authorization Code). The agent acts on behalf of a user who consents via an authorization URL. This is the flow for user-scoped data in Google, GitHub, Slack, and similar. Crucially, AgentCore stores both the access token and the refresh token and auto-refreshes them, which is what minimizes consent fatigue — the user grants access once and subsequent calls reuse the cached, refreshed token until it can no longer be renewed.

The selection is per-decorated-function, so one agent can do M2M to an internal API and USER_FEDERATION to Google within the same codebase.

Key insight

Refresh is the whole point of 3LO

Because AgentCore stores the refresh token and renews silently, the user sees the consent screen once, not on every call. That only works if the provider actually issued a refresh token in the first place — which is exactly the per-provider configuration trap covered next.

Per-provider refresh-token gotchas

3LO only minimizes consent fatigue if the provider issues a refresh token. Most OAuth providers do not hand one out by default — you have to opt in, and the switch is different for every vendor. These settings are volatile (providers change their consoles); treat the list as a starting point and verify against the live provider docs and the AgentCore resource-providers page.

ProviderWhat to set (as of writing — verify live)
Google"access_type": "offline" in customParameters
Microsoft Entrarequest the offline_access scope
Salesforcerequest the refresh_token scope
Atlassian / Jirarequest the offline_access scope
GitHubenable "User-to-server token expiration" in the app
Slackenable "token rotation"
LinkedInenable refresh in the app config

For Google, the offline access-type rides along in the decorator's custom_parameters:

python
@requires_access_token(
    provider_name="google-provider",
    scopes=["https://www.googleapis.com/auth/drive.metadata.readonly"],
    auth_flow="USER_FEDERATION",
    custom_parameters={"access_type": "offline"},   # Google must be told to issue a refresh token
    on_auth_url=lambda url: stream_to_client(url),
)
async def read_from_google_drive(*, access_token: str):
    ...

Tokens are not guaranteed to stay valid. A provider can revoke a token (user changed password, admin pulled the app, rotation kicked in). When a cached token fails, force a fresh consent by setting force_authentication=True, which bypasses the cache and re-runs the authorization flow:

python
@requires_access_token(
    provider_name="google-provider",
    scopes=["https://www.googleapis.com/auth/drive.metadata.readonly"],
    auth_flow="USER_FEDERATION",
    force_authentication=True,        # bypass the cached/expired token, re-prompt for consent
    on_auth_url=lambda url: stream_to_client(url),
)
async def read_from_google_drive(*, access_token: str):
    ...

Watch out

These switches change — verify the live page

Provider OAuth consoles and AgentCore's built-in vendor configs move frequently. The names above are accurate as of writing, but always confirm against the provider's own docs and the AWS resource-providers page before shipping. Treat 'no refresh token issued' as the most common cause of repeated consent prompts.

Example

Symptom -> cause

Users complain the agent re-asks for Google consent every session. Almost always: you didn't pass access_type=offline, so Google never issued a refresh token, so AgentCore has nothing to refresh and falls back to a fresh authorization each time.

The token vault: what it holds and how it isolates

The token vault is the secure credential store at the center of Outbound Auth. It holds:

  • OAuth access tokens and refresh tokens
  • OAuth client credentials
  • API keys (static keys for services like OpenAI, fetched with the @requires_api_key decorator)

Three properties make it production-grade:

  1. Encryption everywhere. Credentials are encrypted at rest and in transit using AWS KMS — either a service-managed key or your own customer-managed key (CMK) if you need control over the key policy and rotation.
  2. Strict per-(agent identity + user id) isolation. Every credential is keyed to a specific (agent workload identity, user id) pair, giving zero token sharing across agents or across users. User A's Google token is unreachable by user B, and your billing agent's tokens are unreachable by your support agent.
  3. Independent validation. Every access request to the vault is validated on its own — there is no ambient "already authenticated" trust.

And the cardinal rule that ties it all together: tokens are never embedded in code or config, and never exposed to the LLM context. The decorator pulls the credential from the vault and injects it directly into your function as the access_token (or api_key) argument; it does not flow through the prompt.

text
Agent  --GetResourceOauth2Token-->  Token Vault  (KMS-encrypted)
                                       |  keyed by (agent identity + user id)
  <--access_token injected as kwarg----+  zero token sharing

Because isolation is per-(agent + user), the same physical vault safely serves a multi-tenant agent: each user's downstream tokens are partitioned, and revoking or rotating one user's grant never touches another's.

Tip

Use a CMK when you need key governance

Service-managed KMS is the zero-config default. Choose a customer-managed key (CMK) when compliance requires that you own the key policy, control rotation, or be able to disable the key to cut off access to all stored credentials at once.

IAM for the workload token: the Oct-13-2025 boundary

How the agent's execution role is allowed to fetch the Workload Access Token changed at GA, and this is a volatile fact tied to a specific date — verify the live runtime-oauth page before relying on it.

Agents created before Oct 13 2025 need the workload-token actions granted explicitly in the execution role:

json
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": [
      "bedrock-agentcore:GetWorkloadAccessToken",
      "bedrock-agentcore:GetWorkloadAccessTokenForJWT",
      "bedrock-agentcore:GetWorkloadAccessTokenForUserId"
    ],
    "Resource": [
      "arn:aws:bedrock-agentcore:<region>:<account>:workload-identity-directory/default",
      "arn:aws:bedrock-agentcore:<region>:<account>:workload-identity-directory/default/workload-identity/agentname-*"
    ]
  }]
}

Agents created on or after Oct 13 2025 rely instead on a Service-Linked Role (SLR) named AWSServiceRoleForBedrockAgentCoreRuntimeIdentity (trusted principal runtime-identity.bedrock-agentcore.amazonaws.com). You no longer hand-write the GetWorkloadAccessToken* grants — the SLR carries them. The caller creating the runtime via the control API just needs iam:CreateServiceLinkedRole for that service so the role can be provisioned.

Keep the two planes straight: IAM governs AWS-level access — the control/data-plane APIs, credential-provider creation, KMS, and the execution role (SigV4 is one inbound option). Identity governs the agent/workload identity — the directory, the token vault, and OAuth 2LO/3LO to non-AWS services. The key IAM actions you will see across outbound auth are GetWorkloadAccessToken* (the exchange) and GetResourceOauth2Token (the vault call); InvokeAgentRuntime / InvokeAgentRuntimeForUser sit on the inbound side.

Watch out

This is date- and version-sensitive — re-verify

The pre/post Oct-13-2025 split and the SLR name are accurate as of writing but are exactly the kind of fact AWS updates. Confirm against the live runtime-oauth and runtime-permissions docs before granting (or removing) the explicit GetWorkloadAccessToken* actions.

Try it: Wire a 3LO Google Drive call through the token vault

Goal: build an agent function that reads a user's Google Drive via 3LO, proves the token never enters the LLM context, and demonstrates the refresh and recovery switches.

  1. Register an outbound credential provider. Using bedrock-agentcore-control, create a Google OAuth2 provider and capture the returned callbackUrl:
    bash
    aws bedrock-agentcore-control create-oauth2-credential-provider --region us-east-1 \
      --name "google-provider" --credential-provider-vendor "GoogleOauth2" \
      --oauth2-provider-config-input '{"googleOauth2ProviderConfig":{"clientId":"...","clientSecret":"..."}}'
    Register the returned https://bedrock-agentcore.<region>.amazonaws.com/identities/oauth2/callback/<uuid> URL in Google's Authorized redirect URIs (forgetting this breaks the 3LO redirect).
  2. Decorate a function for 3LO. Add a @requires_access_token(...) with auth_flow="USER_FEDERATION", a Drive read-only scope, custom_parameters={"access_type": "offline"}, and an on_auth_url that streams the URL back to your client (not print). Make the function take *, access_token: str.
  3. Run the consent once. Invoke the agent, open the surfaced authorization URL, grant consent, and confirm the Drive call succeeds. Then invoke a second time and confirm there is no consent prompt — the cached, refreshed token is reused.
  4. Prove the isolation contract. Add a log line inside your function that prints the length of access_token (never the value). Confirm the raw token appears nowhere in the prompt/response payload — only inside your business logic.
  5. Break and recover. Revoke the app's access in your Google account, invoke again, and observe the failure. Flip the decorator to force_authentication=True, re-invoke, and confirm it re-runs consent and recovers.
  6. Reflect (3 sentences). Where would you switch this same agent to auth_flow="M2M" instead? What changes in IAM if this agent was created after Oct 13 2025 versus before? And why does omitting access_type=offline cause repeated consent prompts?

Key takeaways

  1. 1Outbound Auth lets an agent call downstream APIs (Google, GitHub, Slack, internal services, other MCP servers) with no secret in code, config, or the LLM context — the token is injected as a function kwarg.
  2. 2The Workload Access Token flow is: inbound authorize -> exchange via `GetWorkloadAccessTokenForJWT` (delivered in the `WorkloadAccessToken` header) -> call the vault with `GetResourceOauth2Token` -> user consents via `on_auth_url` -> token cached keyed by (agent identity + user id).
  3. 32LO = Client Credentials = M2M (`auth_flow="M2M"`, agent acts as itself, no consent URL); 3LO = Authorization Code (`auth_flow="USER_FEDERATION"`, user-delegated). AgentCore stores and auto-refreshes 3LO tokens.
  4. 4Refresh tokens are opt-in per provider and the setting is volatile: Google `access_type=offline`, Microsoft `offline_access` scope, GitHub user-to-server token expiration, Slack token rotation. When a cached token fails, set `force_authentication=True`.
  5. 5The token vault holds OAuth tokens, client credentials, and API keys; encrypts them at rest and in transit with KMS (service-managed or CMK); and enforces strict per-(agent + user) isolation = zero token sharing.
  6. 6Workload-token IAM is date-sensitive (volatile): agents before Oct 13 2025 need `GetWorkloadAccessToken*` in the execution role; on/after that date they use the SLR `AWSServiceRoleForBedrockAgentCoreRuntimeIdentity`.

Quiz

Lock in what you learned

Check your understanding

0 / 4 answered

1.In the Workload Access Token flow, what does the agent do immediately after the Runtime exchanges the inbound JWT for a Workload Access Token?

2.Your agent needs to read a user's Google Drive on that user's behalf. Which decorator configuration is correct?

3.Which statement about the token vault is accurate?

4.You create a brand-new AgentCore agent after Oct 13 2025 and it gets a permission error fetching its workload token. What is the most likely fix?

Go deeper

Hand-picked sources to keep learning