Securing a Static JavaScript AI Application with Cognito, OAuth 2.0, PKCE and API Gateway JWT Authorization A developer built the Daara Media AI Creative Content Assistant, an internal AI assistant, securing its static JavaScript frontend with Amazon Cognito managed login, the OAuth 2.0 authorization code flow with PKCE, and an API Gateway JWT authorizer that rejects unauthenticated requests before they reach the chat Lambda. The backend Lambda queries a Bedrock Knowledge Base backed by Claude Sonnet 4.5 and returns a draft with sources. The design separates authentication from API authorization so that frontend login checks are not relied on for enforcement. When building an internal AI assistant, getting the model to generate useful responses is only one part of the engineering challenge. The application also needs to establish who is using it, verify that the user is authenticated, and prevent unauthenticated requests from reaching the backend. This becomes particularly important when the frontend is a static JavaScript application. Unlike a traditional server-rendered application, a static frontend does not have a private backend in which to keep credentials or client secrets. Its JavaScript, configuration, and network requests are visible to anyone who can access the application. For the Daara Media AI Creative Content Assistant, I wanted staff to authenticate before using the application, and I wanted the API to reject unauthorized requests independently of the frontend. I implemented this using four key technologies: This article explains how these components work together, why each security boundary matters, and what I learned while integrating and testing the authentication flow. My frontend is a static HTML, CSS, and JavaScript application. It does not have a server-side session layer that can securely hold a client secret. A naive implementation might hide the chat interface behind a login screen and check whether a user is signed in using JavaScript. However, hiding a button or redirecting unauthenticated users is not sufficient security. A user can bypass frontend checks and send an HTTP request directly to the API using a command-line tool, a script, or an API client. For example: curl -X POST \ "https://YOUR API ID.execute-api.us-east-1.amazonaws.com/chat" \ -H "Content-Type: application/json" \ -d '{"prompt":"Write a caption for our campaign"}' If the backend does not enforce authentication, the request could reach the application even when the frontend requires login. The fundamental design principle is therefore: The frontend controls the user experience. The backend enforces access to protected resources. The first step was to separate authentication from API authorization. Authentication answers the question: Who has successfully signed in? Authorization at the API boundary answers: Does this request present a valid token that satisfies the API's authorization requirements? The architecture looks like this: Staff User | v Static JavaScript UI | | Redirect to sign in v Amazon Cognito Managed Login | | Authorization Code | + PKCE v Static JavaScript UI | | Access token v API Gateway | v JWT Authorizer | +--------+--------+ | | Invalid token Valid token | | v v Reject Chat Lambda | v Bedrock Knowledge Base | v Claude Sonnet 4.5 | v Draft + Sources This diagram shows two distinct security boundaries. First, Cognito authenticates the staff user and participates in the OAuth authorization flow. Second, API Gateway validates the access token before allowing the protected request to reach Lambda. The frontend does not need to be trusted to enforce this second boundary. I chose Amazon Cognito because the application needed a managed identity service rather than a custom authentication implementation. Cognito provides a user pool for managing users and authentication, as well as a hosted sign-in experience through its managed login feature. For this application, the responsibilities are divided as follows: | Component | Responsibility | |---|---| | Cognito User Pool | Manages user identities and authentication | | Cognito managed login | Provides the hosted sign-in experience | | Cognito app client | Defines the browser application's OAuth configuration | | Cognito domain | Provides the authorization and token endpoints | | API Gateway JWT authorizer | Validates tokens on protected API requests | | Lambda | Executes application logic after authorization | This separation means the application does not need to implement password handling, build its own login interface, or write custom JWT signature-validation code in the frontend. It also allows the API to validate tokens independently of how the user interface is implemented. A static JavaScript application is a public OAuth client . A public client cannot reliably protect a client secret because any secret embedded in its JavaScript bundle or browser configuration can be extracted. For this reason, the browser application must not depend on a confidential client secret. Instead, I used OAuth 2.0 Authorization Code Flow with Proof Key for Code Exchange PKCE . PKCE adds a cryptographic binding between the initial authorization request and the subsequent authorization-code exchange. It helps prevent an intercepted authorization code from being redeemed by an attacker who does not possess the corresponding code verifier. PKCE uses two important values: code verifier : A high-entropy random string generated by the client. code challenge : A transformed version of that verifier, usually produced using SHA-256. The browser sends the challenge when it initiates authorization. Later, when exchanging the authorization code for tokens, the client sends the original verifier. Conceptually: Browser Application | | Generate random code verifier | | SHA-256 code verifier v code challenge | v Cognito Authorization Endpoint | | User signs in v Authorization Code | v Browser Application | | Exchange code + code verifier v Cognito Token Endpoint | | Verify PKCE binding v Access Token + Other Tokens The token endpoint checks that the verifier corresponds to the challenge supplied in the original authorization request. An intercepted authorization code alone is therefore insufficient to complete the intended exchange. PKCE does not eliminate every browser security risk. Cross-site scripting, compromised dependencies, and malicious scripts can still threaten tokens accessible to the application. PKCE protects the authorization-code exchange; it is not a substitute for secure frontend development. The implicit flow returns tokens directly through the authorization response instead of exchanging an authorization code. For a modern browser-based application, Authorization Code Flow with PKCE is the preferred approach. It supports the authorization-code exchange without requiring the static application to possess a client secret. The Cognito app client defines how the frontend is allowed to authenticate. For the Daara Media implementation, the important settings included: openid , email , and profile scopes. The local development callback was: http://localhost:8080/ The production deployment needs its own registered callback and sign-out URLs that match the deployed frontend's configuration. After a successful login, Cognito redirects the browser to a registered callback URL with the authorization response. For Authorization Code Flow, the response contains an authorization code and state value. The frontend then completes the code exchange. Cognito validates the callback URL against the URLs registered for the app client. The application must also validate the returned state against the value it generated before redirecting to the authorization endpoint. A simplified configuration looks like this: Cognito User Pool | v App Client | +-- Public client | +-- Authorization Code grant | +-- PKCE | +-- openid, email, profile | +-- Callback URL | +-- Sign-out URL | v Managed Login Domain A mismatch between the configured callback and the URL used by the application can prevent the login flow from completing. This is why localhost and production URLs must be configured deliberately rather than assumed to be interchangeable. The authentication sequence can be broken down into several stages. The browser loads the static frontend. The application checks whether it has a valid authentication state. If the user is not authenticated, it starts the sign-in process. The frontend should not treat the mere presence of a token string as proof that the API will accept it. The API's authorizer remains responsible for validating tokens on protected requests. The frontend generates a cryptographically random code verifier , derives the code challenge , and creates a random state value. The state value helps the client correlate the callback with the authorization request it initiated and defend against login CSRF attacks. The browser is redirected to the Cognito authorization endpoint with parameters conceptually similar to these: response type=code client id=YOUR APP CLIENT ID redirect uri=http://localhost:8080/ scope=openid+email+profile code challenge=GENERATED CHALLENGE code challenge method=S256 state=GENERATED STATE This is an illustrative set of parameters, not a complete production authorization URL. A real implementation must correctly encode values and include the required Cognito domain and other applicable parameters. Cognito presents its managed login experience. After the user successfully authenticates, Cognito redirects the browser to the registered callback URL with an authorization code and the state value. The frontend validates the returned state before proceeding. The frontend sends the authorization code, redirect URI, app client ID, and PKCE verifier to the Cognito token endpoint. Because this is a public client, no client secret is included. Cognito validates the request and returns the available tokens. The frontend sends the access token in the HTTP Authorization header: Authorization: Bearer ACCESS TOKEN The token is sent over HTTPS. The frontend must use the access token for API authorization, not assume that an ID token is interchangeable with an access token. An ID token communicates information about the authenticated user to the client. An access token is intended to authorize access to protected resources. The request reaches API Gateway, where the JWT authorizer checks the token against its configured requirements. If validation fails, the request is rejected before the integration invokes the chat Lambda. If validation succeeds, API Gateway can invoke the configured backend integration. This was one of the most important architectural decisions in the project. I did not want the Lambda function to be the first component responsible for deciding whether a request was authenticated. Instead, I placed JWT authorization at the API boundary. For an API Gateway HTTP API, the JWT authorizer can validate the token's signature using the issuer's public signing keys and check relevant claims, including the issuer, audience or client ID, expiration, and other configured requirements. If route scopes are configured, the authorizer can also enforce the required OAuth scopes. The relevant configuration includes: Authorization header. For this project, the issuer followed this format: https://cognito-idp.us-east-1.amazonaws.com/USER POOL ID The issuer must match the configured User Pool. The API's authorizer configuration must also use the expected app client ID and token format. Incoming HTTP Request | v Extract Bearer Token | v JWT Authorizer | +--------+--------+ | | v v Validate token Validate token fails passes | | v v Reject request Check route scopes | +--------+--------+ | | v v Not allowed Allowed | | v v Reject Invoke Lambda This protects the API even when someone bypasses the frontend entirely. The distinction is important: a static website can be copied or inspected, but a caller cannot make an invalid or expired token pass the API's configured JWT validation merely by modifying the frontend. A valid JWT is not automatically proof that a user should access every resource or perform every operation. JWT validation establishes that the token meets the authorizer's configured requirements. Application-level authorization may still be needed for staff roles, tenant boundaries, sensitive operations, or document-level permissions. Similarly, a JWT authorizer does not validate the AI-generated content, protect against prompt injection by itself, or replace Bedrock Guardrails. Each of these controls addresses a different threat. Once API Gateway authorizes the request, the chat Lambda handles the application workflow. For Daara Media, that workflow involves retrieving relevant company knowledge and generating a draft for human review. Staff Browser | | POST /chat | Authorization: Bearer