Akshay Parkhi's Weblog

Subscribe

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:

  1. Cognito holds a private key (never leaves AWS)
  2. Cognito publishes the matching public key at /.well-known/jwks.json
  3. When the user logs in, Cognito signs a JWT with the private key
  4. Anyone (including AgentCore) can verify the JWT using the public key
  5. 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:

  1. Extract JWT from Authorization header (SSE) or Sec-WebSocket-Protocol header (WebSocket)
  2. Fetch public keys from Cognito’s JWKS endpoint: https://cognito-idp.us-east-1.amazonaws.com/us-east-1_T1b6PvgjJ/.well-known/jwks.json
  3. Verify signature using the public key matching the kid in the JWT header
  4. Check claims:
    • exp > now? (not expired)
    • iss matches configured Cognito pool URL? (right issuer)
    • client_id matches configured app client? (right application)
  5. 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:

TierPatternWhen to use
Tier 1 (this app)CloudFront → direct to AgentCoreStandard web apps, demos, internal tools
Tier 2CloudFront → API Gateway → AgentCore with SigV4Additional request transformation or rate limiting
Tier 3CloudFront → ALB → PrivateLink → AgentCoreStrict 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):

ControlImplementation
S3 fully locked downBlockPublicAcls=true, OAC with specific distribution ARN condition
CloudFront HTTPS-onlyredirect-to-https enforced
JWT validation at edgeAgentCore checks signature, expiry, issuer, client_id
No auth in agent codeBy 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:

IssueRiskFix
Test credentials hardcoded in config.tsAnyone reading source gets a valid loginRemove; use a login form with user-created accounts
No token refresh flowUser gets logged out after ~1 hour (Cognito default expiry)Add refreshToken flow using REFRESH_TOKEN_AUTH
CORS set to * on agentLow risk (agent sits behind AgentCore) but sloppyRestrict to CloudFront domain
No User-Id header hardeningAWS docs warn: user-id should be derived from authenticated principalLet 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.

This is AgentCore Auth from First Principles: How JWT Flows from Browser to Agent Container by Akshay Parkhi, posted on 5th April 2026.

Previous: HTTP vs AG-UI: What Actually Changes in Your React Code