{"slug": "securing-a-static-javascript-ai-application-with-cognito-oauth-2-0-pkce-and-api", "title": "Securing a Static JavaScript AI Application with Cognito, OAuth 2.0, PKCE and API Gateway JWT Authorization", "summary": "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.", "body_md": "When building an internal AI assistant, getting the model to generate useful responses is only one part of the engineering challenge.\n\nThe application also needs to establish who is using it, verify that the user is authenticated, and prevent unauthenticated requests from reaching the backend.\n\nThis 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.\n\nFor 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.\n\nI implemented this using four key technologies:\n\nThis article explains how these components work together, why each security boundary matters, and what I learned while integrating and testing the authentication flow.\n\nMy 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.\n\nA naive implementation might hide the chat interface behind a login screen and check whether a user is signed in using JavaScript.\n\nHowever, hiding a button or redirecting unauthenticated users is not sufficient security.\n\nA 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.\n\nFor example:\n\n```\ncurl -X POST \\\n  \"https://YOUR_API_ID.execute-api.us-east-1.amazonaws.com/chat\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"prompt\":\"Write a caption for our campaign\"}'\n```\n\nIf the backend does not enforce authentication, the request could reach the application even when the frontend requires login.\n\nThe fundamental design principle is therefore:\n\n**The frontend controls the user experience. The backend enforces access to protected resources.**\n\nThe first step was to separate authentication from API authorization.\n\nAuthentication answers the question:\n\nWho has successfully signed in?\n\nAuthorization at the API boundary answers:\n\nDoes this request present a valid token that satisfies the API's authorization requirements?\n\nThe architecture looks like this:\n\n```\n                  Staff User\n                      |\n                      v\n             Static JavaScript UI\n                      |\n                      | Redirect to sign in\n                      v\n              Amazon Cognito\n               Managed Login\n                      |\n                      | Authorization Code\n                      | + PKCE\n                      v\n             Static JavaScript UI\n                      |\n                      | Access token\n                      v\n                API Gateway\n                      |\n                      v\n                JWT Authorizer\n                      |\n             +--------+--------+\n             |                 |\n       Invalid token       Valid token\n             |                 |\n             v                 v\n          Reject         Chat Lambda\n                               |\n                               v\n                    Bedrock Knowledge Base\n                               |\n                               v\n                       Claude Sonnet 4.5\n                               |\n                               v\n                       Draft + Sources\n```\n\nThis diagram shows two distinct security boundaries.\n\nFirst, Cognito authenticates the staff user and participates in the OAuth authorization flow.\n\nSecond, API Gateway validates the access token before allowing the protected request to reach Lambda.\n\nThe frontend does not need to be trusted to enforce this second boundary.\n\nI chose Amazon Cognito because the application needed a managed identity service rather than a custom authentication implementation.\n\nCognito provides a user pool for managing users and authentication, as well as a hosted sign-in experience through its managed login feature.\n\nFor this application, the responsibilities are divided as follows:\n\n| Component | Responsibility | \n|---|---|\n| Cognito User Pool | Manages user identities and authentication | \n| Cognito managed login | Provides the hosted sign-in experience | \n| Cognito app client | Defines the browser application's OAuth configuration | \n| Cognito domain | Provides the authorization and token endpoints | \n| API Gateway JWT authorizer | Validates tokens on protected API requests | \n| Lambda | Executes application logic after authorization | \n\nThis 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.\n\nIt also allows the API to validate tokens independently of how the user interface is implemented.\n\nA static JavaScript application is a **public OAuth client**.\n\nA public client cannot reliably protect a client secret because any secret embedded in its JavaScript bundle or browser configuration can be extracted.\n\nFor this reason, the browser application must not depend on a confidential client secret.\n\nInstead, I used OAuth 2.0 Authorization Code Flow with Proof Key for Code Exchange (PKCE).\n\nPKCE 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.\n\nPKCE uses two important values:\n\n`code_verifier`: A high-entropy random string generated by the client.` code_challenge`: A transformed version of that verifier, usually produced using SHA-256.\nThe browser sends the challenge when it initiates authorization. Later, when exchanging the authorization code for tokens, the client sends the original verifier.\n\nConceptually:\n\n```\nBrowser Application\n        |\n        | Generate random code_verifier\n        |\n        | SHA-256(code_verifier)\n        v\n   code_challenge\n        |\n        v\nCognito Authorization Endpoint\n        |\n        | User signs in\n        v\n Authorization Code\n        |\n        v\nBrowser Application\n        |\n        | Exchange code + code_verifier\n        v\n Cognito Token Endpoint\n        |\n        | Verify PKCE binding\n        v\n Access Token + Other Tokens\n```\n\nThe token endpoint checks that the verifier corresponds to the challenge supplied in the original authorization request.\n\nAn intercepted authorization code alone is therefore insufficient to complete the intended exchange.\n\nPKCE 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.\n\nThe implicit flow returns tokens directly through the authorization response instead of exchanging an authorization code.\n\nFor 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.\n\nThe Cognito app client defines how the frontend is allowed to authenticate.\n\nFor the Daara Media implementation, the important settings included:\n\n`openid`, `email`, and `profile` scopes.\nThe local development callback was:\n\n```\nhttp://localhost:8080/\n```\n\nThe production deployment needs its own registered callback and sign-out URLs that match the deployed frontend's configuration.\n\nAfter a successful login, Cognito redirects the browser to a registered callback URL with the authorization response.\n\nFor Authorization Code Flow, the response contains an authorization code and state value. The frontend then completes the code exchange.\n\nCognito 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.\n\nA simplified configuration looks like this:\n\n```\nCognito User Pool\n        |\n        v\n    App Client\n        |\n        +-- Public client\n        |\n        +-- Authorization Code grant\n        |\n        +-- PKCE\n        |\n        +-- openid, email, profile\n        |\n        +-- Callback URL\n        |\n        +-- Sign-out URL\n        |\n        v\n   Managed Login Domain\n```\n\nA mismatch between the configured callback and the URL used by the application can prevent the login flow from completing.\n\nThis is why localhost and production URLs must be configured deliberately rather than assumed to be interchangeable.\n\nThe authentication sequence can be broken down into several stages.\n\nThe browser loads the static frontend.\n\nThe application checks whether it has a valid authentication state. If the user is not authenticated, it starts the sign-in process.\n\nThe 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.\n\nThe frontend generates a cryptographically random `code_verifier`, derives the `code_challenge`, and creates a random `state` value.\n\nThe state value helps the client correlate the callback with the authorization request it initiated and defend against login CSRF attacks.\n\nThe browser is redirected to the Cognito authorization endpoint with parameters conceptually similar to these:\n\n```\nresponse_type=code\nclient_id=YOUR_APP_CLIENT_ID\nredirect_uri=http://localhost:8080/\nscope=openid+email+profile\ncode_challenge=GENERATED_CHALLENGE\ncode_challenge_method=S256\nstate=GENERATED_STATE\n```\n\nThis 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.\n\nCognito presents its managed login experience.\n\nAfter the user successfully authenticates, Cognito redirects the browser to the registered callback URL with an authorization code and the state value.\n\nThe frontend validates the returned state before proceeding.\n\nThe frontend sends the authorization code, redirect URI, app client ID, and PKCE verifier to the Cognito token endpoint.\n\nBecause this is a public client, no client secret is included.\n\nCognito validates the request and returns the available tokens.\n\nThe frontend sends the access token in the HTTP `Authorization` header:\n\n```\nAuthorization: Bearer ACCESS_TOKEN\n```\n\nThe token is sent over HTTPS.\n\nThe frontend must use the access token for API authorization, not assume that an ID token is interchangeable with an access token.\n\nAn ID token communicates information about the authenticated user to the client. An access token is intended to authorize access to protected resources.\n\nThe request reaches API Gateway, where the JWT authorizer checks the token against its configured requirements.\n\nIf validation fails, the request is rejected before the integration invokes the chat Lambda.\n\nIf validation succeeds, API Gateway can invoke the configured backend integration.\n\nThis was one of the most important architectural decisions in the project.\n\nI did not want the Lambda function to be the first component responsible for deciding whether a request was authenticated.\n\nInstead, I placed JWT authorization at the API boundary.\n\nFor 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.\n\nIf route scopes are configured, the authorizer can also enforce the required OAuth scopes.\n\nThe relevant configuration includes:\n\n`Authorization` header.\nFor this project, the issuer followed this format:\n\n```\nhttps://cognito-idp.us-east-1.amazonaws.com/USER_POOL_ID\n```\n\nThe issuer must match the configured User Pool. The API's authorizer configuration must also use the expected app client ID and token format.\n\n```\n             Incoming HTTP Request\n                      |\n                      v\n              Extract Bearer Token\n                      |\n                      v\n                JWT Authorizer\n                      |\n             +--------+--------+\n             |                 |\n             v                 v\n     Validate token      Validate token\n        fails                passes\n             |                 |\n             v                 v\n      Reject request      Check route scopes\n                               |\n                      +--------+--------+\n                      |                 |\n                      v                 v\n                  Not allowed        Allowed\n                      |                 |\n                      v                 v\n                   Reject        Invoke Lambda\n```\n\nThis protects the API even when someone bypasses the frontend entirely.\n\nThe 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.\n\nA valid JWT is not automatically proof that a user should access every resource or perform every operation.\n\nJWT 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.\n\nSimilarly, a JWT authorizer does not validate the AI-generated content, protect against prompt injection by itself, or replace Bedrock Guardrails.\n\nEach of these controls addresses a different threat.\n\nOnce API Gateway authorizes the request, the chat Lambda handles the application workflow.\n\nFor Daara Media, that workflow involves retrieving relevant company knowledge and generating a draft for human review.\n\n```\nStaff Browser\n      |\n      | POST /chat\n      | Authorization: Bearer <access_token>\n      v\nAPI Gateway HTTP API\n      |\n      v\nJWT Authorizer\n      |\n      | Authorized request\n      v\nChat Orchestrator Lambda\n      |\n      +--------------------------+\n      |                          |\n      v                          v\nBedrock Knowledge Base    Claude Sonnet 4.5\n      |                          ^\n      | Retrieved context        |\n      +--------------------------+\n                 |\n                 v\n           Draft Response\n                 |\n                 v\n            Human Review\n```\n\nThe Lambda function orchestrates the application logic. It is not responsible for replacing Cognito's authentication system or reimplementing API Gateway's JWT validation.\n\nThe frontend sends a request body containing the prompt expected by the backend. In this implementation, the relevant field was `prompt`.\n\n```\n{\n  \"prompt\": \"Draft a social media caption using our brand guidelines.\"\n}\n```\n\nThis is an application contract, separate from the OAuth token.\n\nThe access token answers the authorization question. The request body contains the user's task.\n\nKeeping these concerns separate makes the architecture easier to debug and maintain.\n\nOne lesson from implementing this flow was that successful login does not guarantee a successful AI request.\n\nThe integration crosses multiple boundaries, and each can fail independently.\n\nPotential causes include:\n\nThe 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.\n\nA successful Cognito login establishes an authenticated session, but the API still needs a valid access token.\n\nCheck:\n\n`Bearer` scheme.\nThis is why testing the login screen alone is insufficient. The token must also work at the protected API boundary.\n\nDuring my integration, the frontend initially sent `message`, while the Lambda expected `prompt`.\n\nThe request was rejected with a `400 Bad Request`. Correcting the request body to use `prompt` resolved that mismatch.\n\nThe lesson is simple: **a successful authentication flow does not guarantee that the application request is valid.**\n\nHTTP 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.\n\nThe backend response contract used a `draft` field and a `sources` field.\n\nThe frontend initially failed to read the `draft` property correctly, so the request could succeed without the generated response appearing in the interface.\n\nThe fix was to make the frontend consume the actual response contract.\n\nThis reinforced the need to test the full request lifecycle:\n\n```\nLogin\n  |\n  v\nAccess Token\n  |\n  v\nAPI Authorization\n  |\n  v\nRequest Validation\n  |\n  v\nLambda Execution\n  |\n  v\nBedrock Processing\n  |\n  v\nResponse Parsing\n  |\n  v\nUI Rendering\n```\n\nA system can be secure at the authentication layer and still be broken at the application layer. Both need independent validation.\n\nA browser application has different security constraints from a server-side application.\n\nSeveral considerations follow directly from that difference.\n\nA static JavaScript application cannot protect a client secret. Use a public Cognito app client and Authorization Code Flow with PKCE.\n\nHTTPS protects tokens and request data while they travel between the browser and the service endpoints.\n\nLocal development may use a registered localhost callback, but production callback URLs should use the deployed HTTPS origin.\n\nAvoid writing access tokens, ID tokens, authorization codes, or PKCE verifiers to application logs.\n\nChoose token storage and session-management strategies carefully. Persistent browser storage can increase exposure if malicious JavaScript executes in the application origin. Use short-lived access tokens where appropriate, manage refresh tokens deliberately, and protect the frontend against cross-site scripting.\n\nPKCE protects the authorization-code exchange, but it does not make a compromised browser safe.\n\nDo not rely on hiding UI controls or checking local JavaScript state. Attach the JWT authorizer to the API routes that require authentication.\n\nAuthentication should be combined with least-privilege IAM policies for Lambda execution roles and the AWS resources they access.\n\nA valid staff token should not give the browser direct permission to invoke Bedrock or read private company documents from S3.\n\nThese are related but distinct controls:\n\nNo single control replaces the others.\n\nThe most important lesson was that authentication should not be implemented as a visual feature. It should be part of the application's request architecture.\n\nCognito 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.\n\nThis produces a clear responsibility boundary:\n\n| Layer | Security responsibility | \n|---|---|\n| Static frontend | Initiates login and attaches the access token | \n| Cognito | Authenticates the user and issues tokens | \n| OAuth 2.0 + PKCE | Protects the browser-based authorization-code exchange | \n| API Gateway | Validates tokens and enforces configured route requirements | \n| Lambda | Applies application logic and orchestrates the AI workflow | \n| IAM | Limits AWS resource permissions | \n| Bedrock Guardrails | Applies configured model-input and output safety controls | \n\nThis separation also improves debugging. When something fails, I can investigate the relevant layer instead of treating every error as an authentication problem.\n\nSecuring a static JavaScript AI application requires more than a login page.\n\nFor 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.\n\nOnce a request passes that boundary, Lambda orchestrates the retrieval and generation workflow using the company's knowledge base and Claude Sonnet 4.5.\n\nThe 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.\n\nThe 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.**", "url": "https://wpnews.pro/news/securing-a-static-javascript-ai-application-with-cognito-oauth-2-0-pkce-and-api", "canonical_source": "https://dev.to/duubemmm/securing-a-static-javascript-ai-application-with-cognito-oauth-20-pkce-and-api-gateway-jwt-2j6p", "published_at": "2026-11-05 06:46:02+00:00", "updated_at": "2026-11-05 06:49:32.285937+00:00", "lang": "en", "topics": ["ai-products", "ai-tools", "ai-agents", "developer-tools"], "entities": ["Daara Media", "Amazon Cognito", "API Gateway", "AWS Lambda", "Amazon Bedrock", "Claude Sonnet 4.5"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/securing-a-static-javascript-ai-application-with-cognito-oauth-2-0-pkce-and-api", "markdown": "https://wpnews.pro/news/securing-a-static-javascript-ai-application-with-cognito-oauth-2-0-pkce-and-api.md", "text": "https://wpnews.pro/news/securing-a-static-javascript-ai-application-with-cognito-oauth-2-0-pkce-and-api.txt", "jsonld": "https://wpnews.pro/news/securing-a-static-javascript-ai-application-with-cognito-oauth-2-0-pkce-and-api.jsonld"}}