AgentCore Auth from First Principles: How JWT Flows from Browser to Agent Container
5th April 2026
When you deploy a React frontend on S3+CloudFront that talks directly to AWS AgentCore Runtime — no API Gateway, no Lambda proxy — is that secure? We traced every byte from browser to agent container to find out.
The Architecture
+-----------------+ +------------+ +----------------+
| User's Browser |---->| CloudFront |---->| S3 Bucket |
| | | (CDN) | | (static React) |
| React App | +------------+ +----------------+
| (in browser) |
| | +------------+ +----------------+
| |---->| Cognito | | AgentCore |
| |<----| (OAuth2) | | Runtime |
| | +------------+ | (FastAPI agent)|
| | | |
| |--POST /invocations---->| POST |
| | Authorization: Bearer | (SSE streaming)|
| |<---text/event-stream---| |
| | | |
| |--WSS /ws-------------->| WS /ws |
| | Sec-WebSocket-Protocol| (bidirectional)|
| |<=====frames===========>| |
+-----------------+ +----------------+
No Lambda. No API Gateway. The browser talks directly to https://bedrock-agentcore.us-east-1.amazonaws.com. This matches the AWS-recommended Tier 1 architecture pattern, confirmed by two official sample repos (aws-samples/sample-amazon-bedrock-agentcore-fullstack-webapp and aws-samples/sample-nova-sonic-websocket-agentcore).
Layer 1 — Static Frontend Delivery
S3 bucket: All public access is blocked.
BlockPublicAcls=true
IgnorePublicAcls=true
BlockPublicPolicy=true
RestrictPublicBuckets=true
Nobody can access the bucket directly. Not via S3 URLs, not via the bucket website endpoint.
CloudFront + OAC: CloudFront uses Origin Access Control with SigV4 signing. Every request from CloudFront to S3 is signed. The S3 bucket policy allows only the specific CloudFront distribution:
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::<account>:distribution/<dist-id>"
}
}
HTTPS is enforced via redirect-to-https. SPA routing maps 403 errors to /index.html with 200 status for client-side routing.
First principle: the frontend is static files. CloudFront is the only entity that can read them from S3. Users get them over HTTPS only.
Layer 2 — Authentication
What is a JWT?
A JSON Web Token is a cryptographically signed claim with three parts, base64-encoded and dot-separated:
HEADER.PAYLOAD.SIGNATURE
Header: {"alg": "RS256", "kid": "..."}
Payload: {"sub": "user-id", "client_id": "1n76a3...", "exp": 1712345678,
"iss": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_T1b6PvgjJ"}
Signature: RSA signature over header+payload using Cognito's private key
The trust chain:
- Cognito holds a private key (never leaves AWS)
- Cognito publishes the matching public key at
/.well-known/jwks.json - When the user logs in, Cognito signs a JWT with the private key
- Anyone (including AgentCore) can verify the JWT using the public key
- Nobody can forge a JWT without the private key
No shared secret is needed between Cognito and AgentCore. AgentCore fetches the public key from the well-known URL and verifies the signature. This is the OIDC (OpenID Connect) standard.
The login flow:
Browser Cognito IDP
│ │
│ POST / (InitiateAuth) │
│ { │
│ AuthFlow: USER_PASSWORD_AUTH, │
│ ClientId: "1n76a3qs...", │
│ AuthParameters: { │
│ USERNAME: "demo@example.com"│
│ PASSWORD: "DemoPass123!" │
│ } │
│ } │
│ ────────────────────────────────▶│
│ │ ← Cognito verifies password
│ { │
│ AuthenticationResult: { │
│ AccessToken: "eyJ...", │ ← signed JWT
│ IdToken: "eyJ...", │ ← signed JWT (user identity)
│ RefreshToken: "eyJ...", │ ← for silent refresh
│ } │
│ }◀─────────────────────────────│
│ │
The code uses the AccessToken (not IdToken) for AgentCore. Why? Because AgentCore’s OAuth authorizer validates the client_id claim, which exists in the access token but not the ID token (which has aud instead).
Why the app client has no secret:
aws cognito-idp create-user-pool-client \
--no-generate-secret
The --no-generate-secret flag is required for browser-based apps. JavaScript source is visible to anyone — a client secret would not be secret. This is a public client in OAuth2 terms. Security comes from the user’s password plus Cognito’s JWT signing, not from a client secret.
Token storage: localStorage with a 60-second expiry buffer. If the token will expire within 60 seconds, the stored tokens return null and the user must re-login.
Layer 3 — How the JWT Reaches AgentCore
SSE path — straightforward:
headers["Authorization"] = `Bearer ${token}`;
headers["X-Amzn-Bedrock-AgentCore-Runtime-Session-Id"] = currentSessionId;
Standard OAuth2 Authorization: Bearer header plus an AgentCore-specific session header for conversation continuity.
WebSocket path — the clever part:
The browser WebSocket API does not support custom headers. You cannot send Authorization: Bearer ... on a WebSocket upgrade request. AgentCore solves this with a documented subprotocol trick:
// Base64url-encode the JWT
const base64url = btoa(token)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
// Pass as WebSocket subprotocol
const ws = new WebSocket(wsUrl, [
`base64UrlBearerAuthorization.${base64url}`,
"base64UrlBearerAuthorization",
]);
The JWT is base64url-encoded and embedded in the Sec-WebSocket-Protocol header as a subprotocol name. AgentCore recognizes the base64UrlBearerAuthorization. prefix, extracts the token, and validates it during the handshake.
From the AWS documentation:
The browser’s native WebSocket API does not provide a method to set custom headers during the handshake. To support OAuth authentication from browsers, AgentCore Runtime accepts the bearer token embedded in the Sec-WebSocket-Protocol header.
Layer 4 — What AgentCore Does with the JWT
AgentCore is an AWS managed service. When it receives a request:
- Extract JWT from Authorization header (SSE) or Sec-WebSocket-Protocol header (WebSocket)
- Fetch public keys from Cognito’s JWKS endpoint:
https://cognito-idp.us-east-1.amazonaws.com/us-east-1_T1b6PvgjJ/.well-known/jwks.json - Verify signature using the public key matching the
kidin the JWT header - Check claims:
exp> now? (not expired)issmatches configured Cognito pool URL? (right issuer)client_idmatches configured app client? (right application)
- If valid → forward request to your agent container on port 8080. If invalid → return 401 Unauthorized.
Your FastAPI agent code never sees or validates JWTs. It doesn’t import any auth library. AgentCore handles all authentication before the request reaches your code. Your agent is an inner service; AgentCore is the perimeter.
When configured for JWT, AgentCore validates: discoveryUrl (fetches public keys from JWKS endpoint), allowedClients (checks client_id claim), allowedAudience (checks aud claim), allowedScopes (checks scope claim), and any requiredCustomClaims you configure. No Lambda authorizer needed. No API Gateway needed.
Layer 5 — Session Management
function generateSessionId(): string {
// AgentCore requires session ID >= 33 chars
return crypto.randomUUID() + "-" + crypto.randomUUID().slice(0, 8);
}
The session ID is client-generated (not from the server). It’s sent on every request — via the X-Amzn-Bedrock-AgentCore-Runtime-Session-Id header for SSE, or as a query parameter for WebSocket. AgentCore uses this to maintain conversation context across multiple requests. The session is tied to the authenticated user (via JWT), so one user can’t hijack another’s session.
Layer 6 — URL Construction
const escapedArn = encodeURIComponent(config.agentRuntime.arn);
// URL becomes:
// https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/
// arn%3Aaws%3Abedrock-agentcore%3Aus-east-1%3A030540333189%3Aruntime%2Fagui_document_agent
// /invocations?qualifier=DEFAULT
The ARN of your specific agent runtime is URL-encoded and embedded in the path. This tells AgentCore which registered agent to route to. The qualifier=DEFAULT selects the deployment alias.
AWS Official Validation
This architecture is not a custom invention. AWS documents three tiers:
| Tier | Pattern | When to use |
|---|---|---|
| Tier 1 (this app) | CloudFront → direct to AgentCore | Standard web apps, demos, internal tools |
| Tier 2 | CloudFront → API Gateway → AgentCore with SigV4 | Additional request transformation or rate limiting |
| Tier 3 | CloudFront → ALB → PrivateLink → AgentCore | Strict network isolation requirements |
Two official sample repos use the exact Tier 1 pattern: aws-samples/sample-amazon-bedrock-agentcore-fullstack-webapp (React + Cognito + direct AgentCore) and aws-samples/sample-nova-sonic-websocket-agentcore (direct WebSocket from CloudFront+S3).
Security Assessment
What’s solid (matches AWS recommendations):
| Control | Implementation |
|---|---|
| S3 fully locked down | BlockPublicAcls=true, OAC with specific distribution ARN condition |
| CloudFront HTTPS-only | redirect-to-https enforced |
| JWT validation at edge | AgentCore checks signature, expiry, issuer, client_id |
| No auth in agent code | By design — AgentCore is the security perimeter |
| Public OAuth client | --no-generate-secret — correct for browser apps |
OAuth resource policy uses "Principal": "*" | AWS docs confirm this is required for OAuth mode — security comes from JWT validation, not IAM principals |
What needs production hardening:
| Issue | Risk | Fix |
|---|---|---|
| Test credentials hardcoded in config.ts | Anyone reading source gets a valid login | Remove; use a login form with user-created accounts |
| No token refresh flow | User gets logged out after ~1 hour (Cognito default expiry) | Add refreshToken flow using REFRESH_TOKEN_AUTH |
CORS set to * on agent | Low risk (agent sits behind AgentCore) but sloppy | Restrict to CloudFront domain |
| No User-Id header hardening | AWS docs warn: user-id should be derived from authenticated principal | Let AgentCore derive it from JWT |
The AWS docs themselves note: “This is a reference example.” That applies specifically to the test credentials and missing refresh flow. The architectural pattern — CloudFront → Cognito JWT → direct AgentCore — is the recommended path.
The Key Insight
AgentCore Runtime is not a raw compute endpoint. It’s a managed service with a built-in JWT authorizer. The browser never talks to your FastAPI code directly. AgentCore sits in front, validates every request’s JWT against Cognito’s public keys, and only forwards authenticated traffic to your agent. The four hardening items above are production gaps in a demo app, not architectural flaws in the pattern.
More recent articles
- HTTP vs AG-UI: What Actually Changes in Your React Code - 5th April 2026
- All Four AgentCore Protocols Are Just HTTP: What AG-UI, MCP, and A2A Actually Do - 4th April 2026