Securing a Static JavaScript AI Application with Cognito, OAuth 2.0, PKCE and API Gateway JWT Authorization
Introduction 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 b

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.
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.
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.
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"}' Enter fullscreen mode Exit fullscreen mode 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.
Authorization at the API boundary answers:
Does this request present a valid token that satisfies the API's authorization requirements?
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:
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.
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.
The browser sends the challenge when it initiates authorization. Later, when exchanging the authorization code for tokens, the client sends the original verifier.
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 Enter fullscreen mode Exit fullscreen mode 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:
http://localhost:8080/ Enter fullscreen mode Exit fullscreen mode 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 Enter fullscreen mode Exit fullscreen mode 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 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 Enter fullscreen mode Exit fullscreen mode 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 Enter fullscreen mode Exit fullscreen mode 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.
For this project, the issuer followed this format:
https://cognito-idp.us-east-1.amazonaws.com/USER_POOL_ID Enter fullscreen mode Exit fullscreen mode 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 Enter fullscreen mode Exit fullscreen mode 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 <access_token> v API Gateway HTTP API | v JWT Authorizer | | Authorized request v Chat Orchestrator Lambda | +--------------------------+ | | v v Bedrock Knowledge Base Claude Sonnet 4.5 | ^ | Retrieved context | +--------------------------+ | v Draft Response | v Human Review Enter fullscreen mode Exit fullscreen mode The Lambda function orchestrates the application logic. It is not responsible for replacing Cognito's authentication system or reimplementing API Gateway's JWT validation.
The frontend sends a request body containing the prompt expected by the backend. In this implementation, the relevant field was prompt.
{ "prompt": "Draft a social media caption using our brand guidelines." } Enter fullscreen mode Exit fullscreen mode This is an application contract, separate from the OAuth token.
The access token answers the authorization question. The request body contains the user's task.
Keeping these concerns separate makes the architecture easier to debug and maintain.
One lesson from implementing this flow was that successful login does not guarantee a successful AI request.
The integration crosses multiple boundaries, and each can fail independently.
The right approach is to inspect the authorization request, Cognito configuration, redirect URL, and browser console rather than assuming the password or User Pool is the problem.
A successful Cognito login establishes an authenticated session, but the API still needs a valid access token.
This is why testing the login screen alone is insufficient. The token must also work at the protected API boundary.
During my integration, the frontend initially sent message, while the Lambda expected prompt.
The request was rejected with a 400 Bad Request. Correcting the request body to use prompt resolved that mismatch.
The lesson is simple: a successful authentication flow does not guarantee that the application request is valid.
HTTP status codes must be interpreted in the context of the layer that generated them. A token-validation failure, a malformed request body, a Lambda exception, and an upstream Bedrock error require different debugging approaches.
The backend response contract used a draft field and a sources field.
The frontend initially failed to read the draft property correctly, so the request could succeed without the generated response appearing in the interface.
The fix was to make the frontend consume the actual response contract.
This reinforced the need to test the full request lifecycle:
Login | v Access Token | v API Authorization | v Request Validation | v Lambda Execution | v Bedrock Processing | v Response Parsing | v UI Rendering Enter fullscreen mode Exit fullscreen mode A system can be secure at the authentication layer and still be broken at the application layer. Both need independent validation.
A browser application has different security constraints from a server-side application.
Several considerations follow directly from that difference.
HTTPS protects tokens and request data while they travel between the browser and the service endpoints.
Local development may use a registered localhost callback, but production callback URLs should use the deployed HTTPS origin.
Avoid writing access tokens, ID tokens, authorization codes, or PKCE verifiers to application logs.
PKCE protects the authorization-code exchange, but it does not make a compromised browser safe.
Authentication should be combined with least-privilege IAM policies for Lambda execution roles and the AWS resources they access.
A valid staff token should not give the browser direct permission to invoke Bedrock or read private company documents from S3.
These are related but distinct controls:
The most important lesson was that authentication should not be implemented as a visual feature. It should be part of the application's request architecture.
Cognito allows the application to delegate user authentication to a managed identity service. PKCE adapts the OAuth authorization-code flow to a browser-based public client. API Gateway JWT authorization establishes a separate enforcement point before backend execution.
This produces a clear responsibility boundary:
This separation also improves debugging. When something fails, I can investigate the relevant layer instead of treating every error as an authentication problem.
For the Daara Media AI Creative Content Assistant, I used Amazon Cognito for authentication, OAuth 2.0 Authorization Code Flow with PKCE for the browser-based authorization flow, and API Gateway JWT authorization to protect the backend endpoint.
Once a request passes that boundary, Lambda orchestrates the retrieval and generation workflow using the company's knowledge base and Claude Sonnet 4.5.
The result is a cleaner separation of responsibilities: the browser handles the user experience, Cognito handles identity, API Gateway enforces token validation, and Lambda executes the application workflow.
The broader lesson is that a secure AI application is not just an AI model behind an API. It is a system of independently enforced security and application boundaries, each with a clearly defined responsibility.