Verify Caller Identity

August 25, 2026 · View on GitHub

Agents identify callers from the JWT in the Authorization header, not the request body. This document explains how that check works, why the agent validates the token even though AgentCore already does, and which parts of the deployment depend on it.

How the agent checks tokens

agent-code/shared/auth.py (extract_user_id_from_context) verifies the token signature against the issuer's published JWKS, pinned to RS256, then hands the claims to validate_claims in agent-code/shared/jwt_claims.py, which checks iss, the client, and the presence of sub. Identity is the sub claim.

Every failure path raises — there is no fallback to an unverified decode:

ConditionResult
No Authorization header (or no request headers)reject
COGNITO_ISSUER_URL unsetreject, before decoding
Bad signature, expired, unknown kid, JWKS unreachablereject
iss mismatchreject
Client not in COGNITO_ALLOWED_CLIENTSreject
COGNITO_ALLOWED_CLIENTS emptyaccepted — issuer-only pinning
No subreject

The last two rows are the asymmetry worth knowing: a missing issuer is a hard reject, but a missing client allowlist degrades quietly to "any client of the correct issuer." Set both.

token_use is deliberately not checked, so both Cognito access tokens and ID tokens are accepted as identity.

Why check the token twice?

AgentCore Runtime's CUSTOM_JWT authorizer validates a token before the container sees it. The agent validates it again so the check travels with your code rather than depending on how the runtime happens to be deployed.

runtime_stack.py attaches an authorizer only when the protocol is client-facing (HTTP, MCP, AGUI — see infra_utils/runtime_protocol.py) and a Cognito issuer was supplied. The Authorization header allowlist is gated on exactly the same condition, because the control plane enforces it: creating a runtime that allowlists Authorization without a customJWTAuthorizer is rejected with a validation error (observed live on an A2A deploy). So an A2A runtime, or any runtime built without an issuer, never sees an Authorization header at all — its agent cannot require a caller JWT, and such runtimes are guarded by IAM (InvokeAgentRuntime) instead. The agent-side verification in shared/auth.py still stands on client-facing runtimes: it keeps the check in your code rather than depending on how the runtime happens to be deployed.

The Gateway has its own separate CUSTOM_JWT authorizer, configured the same way in stacks/gateway_stack.py. Runtime and Gateway are independent — configuring one says nothing about the other.

Environment variables

COGNITO_ISSUER_URL and COGNITO_ALLOWED_CLIENTS are injected by app.py, not by RuntimeStack itself, and today only for the orchestrator runtime:

"COGNITO_ISSUER_URL": auth_stack.issuer_url,
"COGNITO_ALLOWED_CLIENTS": f"{auth_stack.app_client_id},{auth_stack.m2m_client_id}",

RuntimeStack's own env_vars carry only PROJECT_NAME, ENVIRONMENT, COMPONENT_NAME, AWS_REGION_NAME and SOURCE_HASH; its cognito_issuer_url / cognito_allowed_clients parameters feed the authorizer, not the container. The A2A runtimes (code-agent, research-agent) receive neither variable. That is harmless only because their entrypoints take no RequestContext and never call the helper — any agent added there that does call it will fail closed until app.py passes the issuer through.

JWKS is fetched on the first verified request and cached by PyJWKClient, which refreshes on a 5-minute lifespan and re-fetches when it sees an unknown key id, so Cognito key rotation is handled. No explicit timeout is set, so a hung JWKS endpoint stalls the request for PyJWT's 30-second default before failing closed.

Machine-to-machine callers

Cognito M2M (client_credentials) access tokens carry client_id instead of aud. Checking only aud would reject machine callers, including scripts/invoke.py. jwt_claims.py prefers aud when present (taking the first element of a list) and falls back to client_id; this is why auth.py passes verify_aud: False and validates the audience itself. app.py includes the M2M client id in COGNITO_ALLOWED_CLIENTS so those callers pass the allowlist.

What the verified identity is used for

The verified sub becomes the AgentCore Memory actor_id, which is the tenant boundary for stored conversation history. An unverified or defaulted identity would place different callers under one actor and mix their history, so agents reject rather than substitute a placeholder.

Which agents verify

Call extract_user_id_from_context: strands-agent, agui-strands-agent, langgraph-agent, agui-langgraph-agent, claude-sdk-agent, claude-sdk-multi-agent.

Extract no identity at all — their entrypoints take no RequestContext: orchestrator, code-agent, research-agent. Note app.py defaults agent_pattern to orchestrator. If you add identity-dependent behaviour to any of these, add the RequestContext parameter and the helper call, and make sure the runtime is passed an issuer.

Agents that import shared/ must also COPY shared/ shared/ in their Dockerfile.

Outbound auth (agent → Gateway)

Separate mechanism, same file: get_gateway_access_token() uses @requires_access_token(provider_name=os.environ["GATEWAY_CREDENTIAL_PROVIDER_NAME"], auth_flow="M2M"). The decorator is evaluated at import time and provider_name defaults to "", so a runtime without that variable binds an empty provider name at module load. The A2A runtimes intentionally do not receive it.

Testing note

Invoking a CUSTOM_JWT runtime through boto3 requires smuggling the bearer token as X-Authorization, but the runtime allowlist forwards only Authorization — the agent then raises "No Authorization header found." scripts/invoke.py (and deploy.sh verify, which composes it) calls the /invocations HTTPS endpoint with a real Authorization: Bearer header instead, exercising the same JWT path production clients use.